Merge branch 'develop' into schrej/refactor
This commit is contained in:
@@ -18,7 +18,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var client = &http.Client{Timeout: time.Hour * 12}
|
||||
var client = &http.Client{
|
||||
Timeout: time.Hour * 12,
|
||||
// Disallow any redirect on a HTTP call. This is a security requirement: do not modify
|
||||
// this logic without first ensuring that the new target location IS NOT within the current
|
||||
// instance's local network.
|
||||
//
|
||||
// This specific error response just causes the client to not follow the redirect and
|
||||
// returns the actual redirect response to the caller. Not perfect, but simple and most
|
||||
// people won't be using URLs that redirect anyways hopefully?
|
||||
//
|
||||
// We'll re-evaluate this down the road if needed.
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
var instance = &Downloader{
|
||||
// Tracks all of the active downloads.
|
||||
downloadCache: make(map[string]*Download),
|
||||
|
||||
@@ -77,7 +77,6 @@ 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 errors.Is(e.err, os.ErrNotExist) {
|
||||
e.logger().Debug("encountered os.IsNotExist error while handling request")
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on the system.",
|
||||
})
|
||||
@@ -122,20 +121,25 @@ func (e *RequestError) Abort(c *gin.Context) {
|
||||
// Looks at the given RequestError and determines if it is a specific filesystem error that
|
||||
// we can process and return differently for the user.
|
||||
func (e *RequestError) getAsFilesystemError() (int, string) {
|
||||
err := errors.Unwrap(e.err)
|
||||
if err == nil {
|
||||
return 0, ""
|
||||
// Some external things end up calling fmt.Errorf() on our filesystem errors
|
||||
// which ends up just unleashing chaos on the system. For the sake of this
|
||||
// fallback to using text checks...
|
||||
if filesystem.IsErrorCode(e.err, filesystem.ErrCodeDenylistFile) || strings.Contains(e.err.Error(), "filesystem: file access prohibited") {
|
||||
return http.StatusForbidden, "This file cannot be modified: present in egg denylist."
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) || filesystem.IsErrorCode(err, filesystem.ErrCodePathResolution) {
|
||||
if filesystem.IsErrorCode(e.err, filesystem.ErrCodePathResolution) || strings.Contains(e.err.Error(), "resolves to a location outside the server root") {
|
||||
return http.StatusNotFound, "The requested resource was not found on the system."
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeDiskSpace) {
|
||||
return http.StatusConflict, "There is not enough disk space available to perform that action."
|
||||
if filesystem.IsErrorCode(e.err, filesystem.ErrCodeIsDirectory) || strings.Contains(e.err.Error(), "filesystem: is a directory") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: file is a directory."
|
||||
}
|
||||
if strings.HasSuffix(err.Error(), "file name too long") {
|
||||
if filesystem.IsErrorCode(e.err, filesystem.ErrCodeDiskSpace) || strings.Contains(e.err.Error(), "filesystem: not enough disk space") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: file is a directory."
|
||||
}
|
||||
if strings.HasSuffix(e.err.Error(), "file name too long") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: file name is too long."
|
||||
}
|
||||
if e, ok := err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||
if e, ok := e.err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||
return http.StatusNotFound, "The requested directory does not exist."
|
||||
}
|
||||
return 0, ""
|
||||
|
||||
315
router/middleware/middleware.go
Normal file
315
router/middleware/middleware.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
)
|
||||
|
||||
// RequestError is a custom error type returned when something goes wrong with
|
||||
// any of the HTTP endpoints.
|
||||
type RequestError struct {
|
||||
err error
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
// NewError returns a new RequestError for the provided error.
|
||||
func NewError(err error) *RequestError {
|
||||
return &RequestError{
|
||||
// Attach a stacktrace to the error if it is missing at this point and mark it
|
||||
// as originating from the location where NewError was called, rather than this
|
||||
// specific point in the code.
|
||||
err: errors.WithStackDepthIf(err, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// SetMessage allows for a custom error message to be set on an existing
|
||||
// RequestError instance.
|
||||
func (re *RequestError) SetMessage(m string) {
|
||||
re.msg = m
|
||||
}
|
||||
|
||||
// SetStatus sets the HTTP status code for the error response. By default this
|
||||
// is a HTTP-500 error.
|
||||
func (re *RequestError) SetStatus(s int) {
|
||||
re.status = s
|
||||
}
|
||||
|
||||
// Abort aborts the given HTTP request with the specified status code and then
|
||||
// logs the event into the logs. The error that is output will include the unique
|
||||
// request ID if it is present.
|
||||
func (re *RequestError) Abort(c *gin.Context, status int) {
|
||||
reqId := c.Writer.Header().Get("X-Request-Id")
|
||||
|
||||
// Generate the base logger instance, attaching the unique request ID and
|
||||
// the URL that was requested.
|
||||
event := log.WithField("request_id", reqId).WithField("url", c.Request.URL.String())
|
||||
// If there is a server present in the gin.Context stack go ahead and pull it
|
||||
// and attach that server UUID to the logs as well so that we can see what specific
|
||||
// server triggered this error.
|
||||
if s, ok := c.Get("server"); ok {
|
||||
if s, ok := s.(*server.Server); ok {
|
||||
event = event.WithField("server_id", s.Id())
|
||||
}
|
||||
}
|
||||
|
||||
if c.Writer.Status() == 200 {
|
||||
// Handle context deadlines being exceeded a little differently since we want
|
||||
// to report a more user-friendly error and a proper error code. The "context
|
||||
// canceled" error is generally when a request is terminated before all of the
|
||||
// logic is finished running.
|
||||
if errors.Is(re.err, context.DeadlineExceeded) {
|
||||
re.SetStatus(http.StatusGatewayTimeout)
|
||||
re.SetMessage("The server could not process this request in time, please try again.")
|
||||
} else if strings.Contains(re.Cause().Error(), "context canceled") {
|
||||
re.SetStatus(http.StatusBadRequest)
|
||||
re.SetMessage("Request aborted by client.")
|
||||
}
|
||||
}
|
||||
|
||||
// c.Writer.Status() will be a non-200 value if the headers have already been sent
|
||||
// to the requester but an error is encountered. This can happen if there is an issue
|
||||
// marshaling a struct placed into a c.JSON() call (or c.AbortWithJSON() call).
|
||||
if status >= 500 || c.Writer.Status() != 200 {
|
||||
event.WithField("status", status).WithField("error", re.err).Error("error while handling HTTP request")
|
||||
} else {
|
||||
event.WithField("status", status).WithField("error", re.err).Debug("error handling HTTP request (not a server error)")
|
||||
}
|
||||
if re.msg == "" {
|
||||
re.msg = "An unexpected error was encountered while processing this request"
|
||||
}
|
||||
// Now abort the request with the error message and include the unique request
|
||||
// ID that was present to make things super easy on people who don't know how
|
||||
// or cannot view the response headers (where X-Request-Id would be present).
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": re.msg, "request_id": reqId})
|
||||
}
|
||||
|
||||
// Cause returns the underlying error.
|
||||
func (re *RequestError) Cause() error {
|
||||
return re.err
|
||||
}
|
||||
|
||||
// Error returns the underlying error message for this request.
|
||||
func (re *RequestError) Error() string {
|
||||
return re.err.Error()
|
||||
}
|
||||
|
||||
// Looks at the given RequestError and determines if it is a specific filesystem
|
||||
// error that we can process and return differently for the user.
|
||||
//
|
||||
// Some external things end up calling fmt.Errorf() on our filesystem errors
|
||||
// which ends up just unleashing chaos on the system. For the sake of this,
|
||||
// fallback to using text checks.
|
||||
//
|
||||
// If the error passed into this call is nil or does not match empty values will
|
||||
// be returned to the caller.
|
||||
func (re *RequestError) asFilesystemError() (int, string) {
|
||||
err := re.Cause()
|
||||
if err == nil {
|
||||
return 0, ""
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeDenylistFile) || strings.Contains(err.Error(), "filesystem: file access prohibited") {
|
||||
return http.StatusForbidden, "This file cannot be modified: present in egg denylist."
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodePathResolution) || strings.Contains(err.Error(), "resolves to a location outside the server root") {
|
||||
return http.StatusNotFound, "The requested resource was not found on the system."
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeIsDirectory) || strings.Contains(err.Error(), "filesystem: is a directory") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: file is a directory."
|
||||
}
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeDiskSpace) || strings.Contains(err.Error(), "filesystem: not enough disk space") {
|
||||
return http.StatusBadRequest, "There is not enough disk space available to perform that action."
|
||||
}
|
||||
if strings.HasSuffix(err.Error(), "file name too long") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: file name is too long."
|
||||
}
|
||||
if e, ok := err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||
return http.StatusNotFound, "The requested directory does not exist."
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// AttachRequestID attaches a unique ID to the incoming HTTP request so that any
|
||||
// errors that are generated or returned to the client will include this reference
|
||||
// allowing for an easier time identifying the specific request that failed for
|
||||
// the user.
|
||||
//
|
||||
// If you are using a tool such as Sentry or Bugsnag for error reporting this is
|
||||
// a great location to also attach this request ID to your error handling logic
|
||||
// so that you can easily cross-reference the errors.
|
||||
func AttachRequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := uuid.New().String()
|
||||
c.Set("request_id", id)
|
||||
c.Set("logger", log.WithField("request_id", id))
|
||||
c.Header("X-Request-Id", id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureAndAbort aborts the request and attaches the provided error to the gin
|
||||
// context so it can be reported properly. If the error is missing a stacktrace
|
||||
// at the time it is called the stack will be attached.
|
||||
func CaptureAndAbort(c *gin.Context, err error) {
|
||||
c.Abort()
|
||||
c.Error(errors.WithStackDepthIf(err, 1))
|
||||
}
|
||||
|
||||
// CaptureErrors is custom handler function allowing for errors bubbled up by
|
||||
// c.Error() to be returned in a standardized format with tracking UUIDs on them
|
||||
// for easier log searching.
|
||||
func CaptureErrors() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
err := c.Errors.Last()
|
||||
if err == nil || err.Err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusInternalServerError
|
||||
if c.Writer.Status() != 200 {
|
||||
status = c.Writer.Status()
|
||||
}
|
||||
if err.Error() == io.EOF.Error() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "The data passed in the request was not in a parsable format. Please try again."})
|
||||
return
|
||||
}
|
||||
captured := NewError(err.Err)
|
||||
if status, msg := captured.asFilesystemError(); msg != "" {
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": msg, "request_id": c.Writer.Header().Get("X-Request-Id")})
|
||||
return
|
||||
}
|
||||
captured.Abort(c, status)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccessControlHeaders sets the access request control headers on all of
|
||||
// the requests.
|
||||
func SetAccessControlHeaders() gin.HandlerFunc {
|
||||
origins := config.Get().AllowedOrigins
|
||||
location := config.Get().PanelLocation
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS")
|
||||
// Maximum age allowable under Chromium v76 is 2 hours, so just use that since
|
||||
// anything higher will be ignored (even if other browsers do allow higher values).
|
||||
//
|
||||
// @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age#Directives
|
||||
c.Header("Access-Control-Max-Age", "7200")
|
||||
c.Header("Access-Control-Allow-Origin", location)
|
||||
c.Header("Access-Control-Allow-Headers", "Accept, Accept-Encoding, Authorization, Cache-Control, Content-Type, Content-Length, Origin, X-Real-IP, X-CSRF-Token")
|
||||
// Validate that the request origin is coming from an allowed origin. Because you
|
||||
// cannot set multiple values here we need to see if the origin is one of the ones
|
||||
// that we allow, and if so return it explicitly. Otherwise, just return the default
|
||||
// origin which is the same URL that the Panel is located at.
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != location {
|
||||
for _, o := range origins {
|
||||
if o != "*" && o != origin {
|
||||
continue
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", o)
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ServerExists will ensure that the requested server exists in this setup.
|
||||
// Returns a 404 if we cannot locate it. If the server is found it is set into
|
||||
// the request context, and the logger for the context is also updated to include
|
||||
// the server ID in the fields list.
|
||||
func ServerExists() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
s := server.GetServers().Find(func(s *server.Server) bool {
|
||||
return c.Param("server") == s.Id()
|
||||
})
|
||||
if s == nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "The requested resource does not exist on this instance."})
|
||||
return
|
||||
}
|
||||
c.Set("logger", ExtractLogger(c).WithField("server_id", s.Id()))
|
||||
c.Set("server", s)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuthorization 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 RequireAuthorization() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// We don't put this value outside this function since the node's authentication
|
||||
// token can be changed on the fly and the config.Get() call returns a copy, so
|
||||
// if it is rotated this value will never properly get updated.
|
||||
token := config.Get().AuthenticationToken
|
||||
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
|
||||
}
|
||||
|
||||
// All requests to Wings must be authorized with the authentication token present in
|
||||
// the Wings configuration file. Remeber, all requests to Wings come from the Panel
|
||||
// backend, or using a signed JWT for temporary authentication.
|
||||
if subtle.ConstantTimeCompare([]byte(auth[1]), []byte(token)) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "You are not authorized to access this endpoint."})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteDownloadEnabled checks if remote downloads are enabled for this instance
|
||||
// and if not aborts the request.
|
||||
func RemoteDownloadEnabled() gin.HandlerFunc {
|
||||
disabled := config.Get().Api.DisableRemoteDownload
|
||||
return func(c *gin.Context) {
|
||||
if disabled {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "This functionality is not currently enabled on this instance."})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractLogger pulls the logger out of the request context and returns it. By
|
||||
// default this will include the request ID, but may also include the server ID
|
||||
// if that middleware has been used in the chain by the time it is called.
|
||||
func ExtractLogger(c *gin.Context) *log.Entry {
|
||||
v, ok := c.Get("logger")
|
||||
if !ok {
|
||||
panic("middleware/middleware: cannot extract logger: not present in request context")
|
||||
}
|
||||
return v.(*log.Entry)
|
||||
}
|
||||
|
||||
// ExtractServer will return the server from the gin.Context or panic if it is
|
||||
// not present.
|
||||
func ExtractServer(c *gin.Context) *server.Server {
|
||||
v, ok := c.Get("server")
|
||||
if !ok {
|
||||
panic("middleware/middleware: cannot extract server: not present in request context")
|
||||
}
|
||||
return v.(*server.Server)
|
||||
}
|
||||
@@ -3,37 +3,33 @@ package router
|
||||
import (
|
||||
"github.com/apex/log"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/router/middleware"
|
||||
)
|
||||
|
||||
// Configures the routing infrastructure for this daemon instance.
|
||||
func Configure(serverManager server.Manager) *gin.Engine {
|
||||
// Configure configures the routing infrastructure for this daemon instance.
|
||||
func Configure() *gin.Engine {
|
||||
gin.SetMode("release")
|
||||
|
||||
m := Middleware{
|
||||
serverManager,
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery(), m.ErrorHandler(), m.SetAccessControlHeaders(), m.WithServerManager())
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(middleware.AttachRequestID(), middleware.CaptureErrors(), middleware.SetAccessControlHeaders())
|
||||
// @todo log this into a different file so you can setup IP blocking for abusive requests and such.
|
||||
// This should still dump requests in debug mode since it does help with understanding the request
|
||||
// lifecycle and quickly seeing what was called leading to the logs. However, it isn't feasible to mix
|
||||
// this output in production and still get meaningful logs from it since they'll likely just be a huge
|
||||
// spamfest.
|
||||
router.Use()
|
||||
router.Use(gin.LoggerWithFormatter(func(params gin.LogFormatterParams) string {
|
||||
log.WithFields(log.Fields{
|
||||
"client_ip": params.ClientIP,
|
||||
"status": params.StatusCode,
|
||||
"latency": params.Latency,
|
||||
"client_ip": params.ClientIP,
|
||||
"status": params.StatusCode,
|
||||
"latency": params.Latency,
|
||||
"request_id": params.Keys["request_id"],
|
||||
}).Debugf("%s %s", params.MethodColor()+params.Method+params.ResetColor(), params.Path)
|
||||
|
||||
return ""
|
||||
}))
|
||||
|
||||
router.OPTIONS("/api/system", func(c *gin.Context) {
|
||||
c.Status(200)
|
||||
})
|
||||
|
||||
// These routes use signed URLs to validate access to the resource being requested.
|
||||
router.GET("/download/backup", getDownloadBackup)
|
||||
router.GET("/download/file", getDownloadFile)
|
||||
@@ -42,16 +38,16 @@ func Configure(serverManager server.Manager) *gin.Engine {
|
||||
// This route is special it sits above all of the other requests because we are
|
||||
// using a JWT to authorize access to it, therefore it needs to be publicly
|
||||
// accessible.
|
||||
router.GET("/api/servers/:server/ws", m.ServerExists(), getServerWebsocket)
|
||||
router.GET("/api/servers/:server/ws", middleware.ServerExists(), getServerWebsocket)
|
||||
|
||||
// This request is called by another daemon when a server is going to be transferred out.
|
||||
// This request does not need the AuthorizationMiddleware as the panel should never call it
|
||||
// and requests are authenticated through a JWT the panel issues to the other daemon.
|
||||
router.GET("/api/servers/:server/archive", m.ServerExists(), getServerArchive)
|
||||
router.GET("/api/servers/:server/archive", middleware.ServerExists(), getServerArchive)
|
||||
|
||||
// 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(m.RequireAuthorization())
|
||||
protected := router.Use(middleware.RequireAuthorization())
|
||||
protected.POST("/api/update", postUpdateConfiguration)
|
||||
protected.GET("/api/system", getSystemInformation)
|
||||
protected.GET("/api/servers", getAllServers)
|
||||
@@ -61,7 +57,7 @@ func Configure(serverManager server.Manager) *gin.Engine {
|
||||
// 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(m.RequireAuthorization(), m.ServerExists())
|
||||
server.Use(middleware.RequireAuthorization(), middleware.ServerExists())
|
||||
{
|
||||
server.GET("", getServer)
|
||||
server.PATCH("", patchServer)
|
||||
@@ -91,9 +87,9 @@ func Configure(serverManager server.Manager) *gin.Engine {
|
||||
files.POST("/decompress", postServerDecompressFiles)
|
||||
files.POST("/chmod", postServerChmodFile)
|
||||
|
||||
files.GET("/pull", getServerPullingFiles)
|
||||
files.POST("/pull", postServerPullRemoteFile)
|
||||
files.DELETE("/pull/:download", deleteServerPullRemoteFile)
|
||||
files.GET("/pull", middleware.RemoteDownloadEnabled(), getServerPullingFiles)
|
||||
files.POST("/pull", middleware.RemoteDownloadEnabled(), postServerPullRemoteFile)
|
||||
files.DELETE("/pull/:download", middleware.RemoteDownloadEnabled(), deleteServerPullRemoteFile)
|
||||
}
|
||||
|
||||
backup := server.Group("/backup")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -15,47 +16,41 @@ import (
|
||||
"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/filesystem"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Returns the contents of a file on the server.
|
||||
// getServerFileContents returns the contents of a file on the server.
|
||||
func getServerFileContents(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
f := c.Query("file")
|
||||
p := "/" + strings.TrimLeft(f, "/")
|
||||
st, err := s.Filesystem().Stat(p)
|
||||
s := middleware.ExtractServer(c)
|
||||
p := "/" + strings.TrimLeft(c.Query("file"), "/")
|
||||
f, st, err := s.Filesystem().File(p)
|
||||
if err != nil {
|
||||
WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
c.Header("X-Mime-Type", st.Mimetype)
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.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-Disposition", "attachment; filename="+st.Name())
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
}
|
||||
|
||||
// TODO(dane): should probably come up with a different approach here. If an error is encountered
|
||||
// by this Readfile call you'll end up causing a (recovered) panic in the program because so many
|
||||
// headers have already been set. We should probably add a RawReadfile that just returns the file
|
||||
// to be read and then we can stream from that safely without error.
|
||||
//
|
||||
// Until that becomes a problem though I'm just going to leave this how it is. The panic is recovered
|
||||
// and a normal 500 error is returned to the client to my knowledge. It is also very unlikely to
|
||||
// happen since we're doing so much before this point that would normally throw an error if there
|
||||
// was a problem with the file.
|
||||
if err := s.Filesystem().Readfile(p, c.Writer); err != nil {
|
||||
WithError(c, err)
|
||||
defer c.Writer.Flush()
|
||||
_, err = bufio.NewReader(f).WriteTo(c.Writer)
|
||||
if err != nil {
|
||||
// Pretty sure this will unleash chaos on the response, but its a risk we can
|
||||
// take since a panic will at least be recovered and this should be incredibly
|
||||
// rare?
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// Returns the contents of a directory for a server.
|
||||
@@ -94,8 +89,7 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
g, ctx := errgroup.WithContext(c.Request.Context())
|
||||
// Loop over the array of files passed in and perform the move or rename action against each.
|
||||
for _, p := range data.Files {
|
||||
pf := path.Join(data.Root, p.From)
|
||||
@@ -106,16 +100,20 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := s.Filesystem().Rename(pf, pt); err != nil {
|
||||
fs := s.Filesystem()
|
||||
// Ignore renames on a file that is on the denylist (both as the rename from or
|
||||
// the rename to value).
|
||||
if err := fs.IsIgnored(pf, pt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Rename(pf, pt); err != nil {
|
||||
// Return nil if the error is an is not exists.
|
||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
})
|
||||
@@ -148,6 +146,10 @@ func postServerCopyFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem().IsIgnored(data.Location); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
return
|
||||
}
|
||||
if err := s.Filesystem().Copy(data.Location); err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
@@ -208,6 +210,10 @@ func postServerWriteFile(c *gin.Context) {
|
||||
f := c.Query("file")
|
||||
f = "/" + strings.TrimLeft(f, "/")
|
||||
|
||||
if err := s.Filesystem().IsIgnored(f); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
return
|
||||
}
|
||||
if err := s.Filesystem().Writefile(f, c.Request.Body); err != nil {
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeIsDirectory) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
@@ -359,69 +365,53 @@ func postServerCompressFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &filesystem.Stat{
|
||||
Info: f,
|
||||
FileInfo: f,
|
||||
Mimetype: "application/tar+gzip",
|
||||
})
|
||||
}
|
||||
|
||||
// postServerDecompressFiles receives the HTTP request and starts the process
|
||||
// of unpacking an archive that exists on the server into the provided RootPath
|
||||
// for the server.
|
||||
func postServerDecompressFiles(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
|
||||
s := middleware.ExtractServer(c)
|
||||
lg := middleware.ExtractLogger(c)
|
||||
var data struct {
|
||||
RootPath string `json:"root"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
if err := c.BindJSON(&data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hasSpace, err := s.Filesystem().SpaceAvailableForDecompression(data.RootPath, data.File)
|
||||
lg = lg.WithFields(log.Fields{"root_path": data.RootPath, "file": data.File})
|
||||
lg.Debug("checking if space is available for file decompression")
|
||||
err := s.Filesystem().SpaceAvailableForDecompression(data.RootPath, data.File)
|
||||
if err != nil {
|
||||
// Handle an unknown format error.
|
||||
if filesystem.IsErrorCode(err, filesystem.ErrCodeUnknownArchive) {
|
||||
s.Log().WithField("error", err).Warn("failed to decompress file due to unknown format")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unknown archive format",
|
||||
})
|
||||
lg.WithField("error", err).Warn("failed to decompress file: unknown archive format")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "The archive provided is in a format Wings does not understand."})
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).Abort(c)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasSpace {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "This server does not have enough available disk space to decompress this archive.",
|
||||
})
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("starting file decompression")
|
||||
if err := s.Filesystem().DecompressFile(data.RootPath, data.File); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested archive was not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If the file is busy for some reason just return a nicer error to the user since there is not
|
||||
// much we specifically can do. They'll need to stop the running server process in order to overwrite
|
||||
// a file like this.
|
||||
if strings.Contains(err.Error(), "text file busy") {
|
||||
s.Log().WithField("error", err).Warn("failed to decompress file due to busy text file")
|
||||
|
||||
lg.WithField("error", err).Warn("failed to decompress file: text file busy")
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "One or more files this archive is attempting to overwrite are currently in use by another process. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -539,14 +529,14 @@ func postServerUploadFiles(c *gin.Context) {
|
||||
for _, header := range headers {
|
||||
p, err := s.Filesystem().SafePath(filepath.Join(directory, header.Filename))
|
||||
if err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
NewServerError(err, s).Abort(c)
|
||||
return
|
||||
}
|
||||
|
||||
// We run this in a different method so I can use defer without any of
|
||||
// the consequences caused by calling it in a loop.
|
||||
if err := handleFileUpload(p, s, header); err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
NewServerError(err, s).Abort(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -559,6 +549,9 @@ func handleFileUpload(p string, s *server.Server, header *multipart.FileHeader)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := s.Filesystem().IsIgnored(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Filesystem().Writefile(p, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,37 +73,29 @@ func postCreateServer(c *gin.Context) {
|
||||
c.Status(http.StatusAccepted)
|
||||
}
|
||||
|
||||
// Updates the running configuration for this daemon instance.
|
||||
// Updates the running configuration for this Wings instance.
|
||||
func postUpdateConfiguration(c *gin.Context) {
|
||||
// A backup of the configuration for error purposes.
|
||||
ccopy := *config.Get()
|
||||
// A copy of the configuration we're using to bind the data received into.
|
||||
cfg := *config.Get()
|
||||
|
||||
// BindJSON sends 400 if the request fails, all we need to do is return
|
||||
cfg := config.Get()
|
||||
if err := c.BindJSON(&cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the SSL certificates the same since the Panel will send through Lets Encrypt
|
||||
// default locations. However, if we picked a different location manually we don't
|
||||
// want to override that.
|
||||
//
|
||||
// If you pass through manual locations in the API call this logic will be skipped.
|
||||
if strings.HasPrefix(cfg.Api.Ssl.KeyFile, "/etc/letsencrypt/live/") {
|
||||
cfg.Api.Ssl.KeyFile = strings.ToLower(ccopy.Api.Ssl.KeyFile)
|
||||
cfg.Api.Ssl.CertificateFile = strings.ToLower(ccopy.Api.Ssl.CertificateFile)
|
||||
cfg.Api.Ssl.KeyFile = strings.ToLower(config.Get().Api.Ssl.KeyFile)
|
||||
cfg.Api.Ssl.CertificateFile = strings.ToLower(config.Get().Api.Ssl.CertificateFile)
|
||||
}
|
||||
|
||||
config.Set(&cfg)
|
||||
if err := config.Get().WriteToDisk(); err != nil {
|
||||
// If there was an error writing to the disk, revert back to the configuration we had
|
||||
// before this code was run.
|
||||
config.Set(&ccopy)
|
||||
|
||||
NewTrackedError(err).Abort(c)
|
||||
// Try to write this new configuration to the disk before updating our global
|
||||
// state with it.
|
||||
if err := config.WriteToDisk(cfg); err != nil {
|
||||
WithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Since we wrote it to the disk successfully now update the global configuration
|
||||
// state to use this new configuration struct.
|
||||
config.Set(cfg)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func getServerArchive(c *gin.Context) {
|
||||
|
||||
c.Header("X-Checksum", checksum)
|
||||
c.Header("X-Mime-Type", st.Mimetype)
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Size())))
|
||||
c.Header("Content-Disposition", "attachment; filename="+s.Archiver.Name())
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user