Compare commits
7 Commits
release/v1
...
v1.11.0-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105f0150f6 | ||
|
|
aeec51632e | ||
|
|
ff50d0e5bd | ||
|
|
9226ccae31 | ||
|
|
2fd0edbff9 | ||
|
|
1457470fff | ||
|
|
da94f750ad |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## v1.11.0-rc.2
|
||||
### Added
|
||||
* More detailed information returned by the `/api/system` endpoint when using the `?v=2` query parameter.
|
||||
|
||||
### Changed
|
||||
* Send reinstallation status separately from installation status.
|
||||
|
||||
### Fixed
|
||||
* Fixed servers outgoing IP not being updated whenever a server's primary allocation is changed when using the Force Outgoing IP option.
|
||||
* Fixed servers being terminated rather than gracefully stopped when a signal is used to stop the container rather than a command.
|
||||
* Fixed file not found errors being treated as an internal error, they are now treated as a 404.
|
||||
|
||||
## v1.11.0-rc.1
|
||||
### Changed
|
||||
* Wings release versions will now follow the major and minor version of the panel.
|
||||
|
||||
20
README.md
20
README.md
@@ -5,6 +5,7 @@
|
||||
[](https://goreportcard.com/report/github.com/pterodactyl/wings)
|
||||
|
||||
# Pterodactyl Wings
|
||||
|
||||
Wings is Pterodactyl's server control plane, built for the rapidly changing gaming industry and designed to be
|
||||
highly performant and secure. Wings provides an HTTP API allowing you to interface directly with running server
|
||||
instances, fetch server logs, generate backups, and control all aspects of the server lifecycle.
|
||||
@@ -13,31 +14,32 @@ In addition, Wings ships with a built-in SFTP server allowing your system to rem
|
||||
dependencies, and allowing users to authenticate with the same credentials they would normally use to access the Panel.
|
||||
|
||||
## Sponsors
|
||||
|
||||
I would like to extend my sincere thanks to the following sponsors for helping find Pterodactyl's developement.
|
||||
[Interested in becoming a sponsor?](https://github.com/sponsors/matthewpi)
|
||||
|
||||
| Company | About |
|
||||
| ------- | ----- |
|
||||
|-----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [**WISP**](https://wisp.gg) | Extra features. |
|
||||
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
|
||||
| [**Fragnet**](https://fragnet.net) | Providing low latency, high-end game hosting solutions to gamers, game studios and eSports platforms. |
|
||||
| [**Tempest**](https://tempest.net/) | Tempest Hosting is a subsidiary of Path Network, Inc. offering unmetered DDoS protected 10Gbps dedicated servers, starting at just $80/month. Full anycast, tons of filters. |
|
||||
| [**Bloom.host**](https://bloom.host) | Bloom.host offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
|
||||
| [**RocketNode**](https://rocketnode.com/) | Innovative game server hosting combined with a straightforward control panel, affordable prices, and Rocket-Fast support. |
|
||||
| [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. |
|
||||
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
|
||||
| [**MineStrator**](https://minestrator.com/) | Looking for the most highend French hosting company for your minecraft server? More than 24,000 members on our discord trust us. Give us a try! |
|
||||
| [**Skynode**](https://www.skynode.pro/) | Skynode provides blazing fast game servers along with a top-notch user experience. Whatever our clients are looking for, we're able to provide it! |
|
||||
| [**DeinServerHost**](https://deinserverhost.de/) | DeinServerHost offers Dedicated, vps and Gameservers for many popular Games like Minecraft and Rust in Germany since 2013. |
|
||||
| [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. |
|
||||
| [**HostEZ**](https://hostez.io) | Providing North America Valheim, Minecraft and other popular games with low latency, high uptime and maximum availability. EZ! |
|
||||
| [**VibeGAMES**](https://vibegames.net/) | VibeGAMES is a game server provider that specializes in DDOS protection for the games we offer. We have multiple locations in the US, Brazil, France, Germany, Singapore, Australia and South Africa.|
|
||||
| [**Gamenodes**](https://gamenodes.nl) | Gamenodes love quality. For Minecraft, Discord Bots and other services, among others. With our own programmers, we provide just that little bit of extra service! |
|
||||
| [**VibeGAMES**](https://vibegames.net/) | VibeGAMES is a game server provider that specializes in DDOS protection for the games we offer. We have multiple locations in the US, Brazil, France, Germany, Singapore, Australia and South Africa. |
|
||||
| [**Pterodactyl Market**](https://pterodactylmarket.com/) | Pterodactyl Market is a one-and-stop shop for Pterodactyl. In our market, you can find Add-ons, Themes, Eggs, and more for Pterodactyl. |
|
||||
| [**UltraServers**](https://ultraservers.com/) | Deploy premium games hosting with the click of a button. Manage and swap games with ease and let us take care of the rest. We currently support Minecraft, Rust, ARK, 7 Days to Die, Garys MOD, CS:GO, Satisfactory and others. |
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
|
||||
* [Wings Documentation](https://pterodactyl.io/wings/1.0/installing.html)
|
||||
* [Community Guides](https://pterodactyl.io/community/about.html)
|
||||
* Or, get additional help [via Discord](https://discord.gg/pterodactyl)
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Please use the [pterodactyl/panel](https://github.com/pterodactyl/panel) repository to report any issues or make
|
||||
feature requests for Wings. In addition, the [security policy](https://github.com/pterodactyl/panel/security/policy) listed
|
||||
within that repository also applies to Wings.
|
||||
|
||||
@@ -196,7 +196,7 @@ func (e *Environment) Create() error {
|
||||
networkMode := container.NetworkMode(cfg.Docker.Network.Mode)
|
||||
if a.ForceOutgoingIP {
|
||||
e.log().Debug("environment/docker: forcing outgoing IP address")
|
||||
networkName := strings.ReplaceAll(e.Id, "-", "")
|
||||
networkName := "ip-" + strings.ReplaceAll(strings.ReplaceAll(a.DefaultMapping.Ip, ".", "-"), ":", "-")
|
||||
networkMode = container.NetworkMode(networkName)
|
||||
|
||||
if _, err := e.client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{}); err != nil {
|
||||
|
||||
@@ -179,8 +179,12 @@ func (e *Environment) Stop(ctx context.Context) error {
|
||||
|
||||
// Allow the stop action to run for however long it takes, similar to executing a command
|
||||
// and using a different logic pathway to wait for the container to stop successfully.
|
||||
t := time.Duration(-1)
|
||||
if err := e.client.ContainerStop(ctx, e.Id, &t); err != nil {
|
||||
//
|
||||
// Using a negative timeout here will allow the container to stop gracefully,
|
||||
// rather than forcefully terminating it, this value MUST be at least 1
|
||||
// second, otherwise it will be ignored.
|
||||
timeout := -1 * time.Second
|
||||
if err := e.client.ContainerStop(ctx, e.Id, &timeout); err != nil {
|
||||
// If the container does not exist just mark the process as stopped and return without
|
||||
// an error.
|
||||
if client.IsErrNotFound(err) {
|
||||
|
||||
@@ -29,7 +29,7 @@ type Client interface {
|
||||
SetArchiveStatus(ctx context.Context, uuid string, successful bool) error
|
||||
SetBackupStatus(ctx context.Context, backup string, data BackupRequest) error
|
||||
SendRestorationStatus(ctx context.Context, backup string, successful bool) error
|
||||
SetInstallationStatus(ctx context.Context, uuid string, successful bool) error
|
||||
SetInstallationStatus(ctx context.Context, uuid string, data InstallStatusRequest) error
|
||||
SetTransferStatus(ctx context.Context, uuid string, successful bool) error
|
||||
ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error)
|
||||
SendActivityLogs(ctx context.Context, activity []models.Activity) error
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
ProcessStopNativeStop = "stop"
|
||||
)
|
||||
|
||||
// GetServers returns all of the servers that are present on the Panel making
|
||||
// GetServers returns all the servers that are present on the Panel making
|
||||
// parallel API calls to the endpoint if more than one page of servers is
|
||||
// returned.
|
||||
func (c *client) GetServers(ctx context.Context, limit int) ([]RawServerData, error) {
|
||||
@@ -58,7 +58,7 @@ func (c *client) GetServers(ctx context.Context, limit int) ([]RawServerData, er
|
||||
//
|
||||
// This handles Wings exiting during either of these processes which will leave
|
||||
// things in a bad state within the Panel. This API call is executed once Wings
|
||||
// has fully booted all of the servers.
|
||||
// has fully booted all the servers.
|
||||
func (c *client) ResetServersState(ctx context.Context) error {
|
||||
res, err := c.Post(ctx, "/servers/reset", nil)
|
||||
if err != nil {
|
||||
@@ -92,8 +92,8 @@ func (c *client) GetInstallationScript(ctx context.Context, uuid string) (Instal
|
||||
return config, err
|
||||
}
|
||||
|
||||
func (c *client) SetInstallationStatus(ctx context.Context, uuid string, successful bool) error {
|
||||
resp, err := c.Post(ctx, fmt.Sprintf("/servers/%s/install", uuid), d{"successful": successful})
|
||||
func (c *client) SetInstallationStatus(ctx context.Context, uuid string, data InstallStatusRequest) error {
|
||||
resp, err := c.Post(ctx, fmt.Sprintf("/servers/%s/install", uuid), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (c *client) SetTransferStatus(ctx context.Context, uuid string, successful
|
||||
// password combination provided is associated with a valid server on the instance
|
||||
// using the Panel's authentication control mechanisms. This will get itself
|
||||
// throttled if too many requests are made, allowing us to completely offload
|
||||
// all of the authorization security logic to the Panel.
|
||||
// all the authorization security logic to the Panel.
|
||||
func (c *client) ValidateSftpCredentials(ctx context.Context, request SftpAuthRequest) (SftpAuthResponse, error) {
|
||||
var auth SftpAuthResponse
|
||||
res, err := c.Post(ctx, "/sftp/auth", request)
|
||||
|
||||
@@ -92,8 +92,8 @@ type SftpAuthResponse struct {
|
||||
}
|
||||
|
||||
type OutputLineMatcher struct {
|
||||
// The raw string to match against. This may or may not be prefixed with
|
||||
// regex: which indicates we want to match against the regex expression.
|
||||
// raw string to match against. This may or may not be prefixed with
|
||||
// `regex:` which indicates we want to match against the regex expression.
|
||||
raw []byte
|
||||
reg *regexp.Regexp
|
||||
}
|
||||
@@ -139,9 +139,9 @@ type ProcessStopConfiguration struct {
|
||||
}
|
||||
|
||||
// ProcessConfiguration defines the process configuration for a given server
|
||||
// instance. This sets what Wings is looking for to mark a server as done starting
|
||||
// what to do when stopping, and what changes to make to the configuration file
|
||||
// for a server.
|
||||
// instance. This sets what Wings is looking for to mark a server as done
|
||||
// starting what to do when stopping, and what changes to make to the
|
||||
// configuration file for a server.
|
||||
type ProcessConfiguration struct {
|
||||
Startup struct {
|
||||
Done []*OutputLineMatcher `json:"done"`
|
||||
@@ -169,3 +169,8 @@ type BackupRequest struct {
|
||||
Successful bool `json:"successful"`
|
||||
Parts []BackupPart `json:"parts"`
|
||||
}
|
||||
|
||||
type InstallStatusRequest struct {
|
||||
Successful bool `json:"successful"`
|
||||
Reinstall bool `json:"reinstall"`
|
||||
}
|
||||
|
||||
157
router/error.go
157
router/error.go
@@ -1,157 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
err error
|
||||
uuid string
|
||||
message string
|
||||
server *server.Server
|
||||
}
|
||||
|
||||
// Attaches an error to the gin.Context object for the request and ensures that it
|
||||
// has a proper stacktrace associated with it when doing so.
|
||||
//
|
||||
// If you just call c.Error(err) without using this function you'll likely end up
|
||||
// with an error that has no annotated stack on it.
|
||||
func WithError(c *gin.Context, err error) error {
|
||||
return c.Error(errors.WithStackDepthIf(err, 1))
|
||||
}
|
||||
|
||||
// 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 NewTrackedError(err error) *RequestError {
|
||||
return &RequestError{
|
||||
err: err,
|
||||
uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Same as NewTrackedError, except this will also attach the server instance that
|
||||
// generated this server for the purposes of logging.
|
||||
func NewServerError(err error, s *server.Server) *RequestError {
|
||||
return &RequestError{
|
||||
err: err,
|
||||
uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||
server: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *RequestError) logger() *log.Entry {
|
||||
if e.server != nil {
|
||||
return e.server.Log().WithField("error_id", e.uuid).WithField("error", e.err)
|
||||
}
|
||||
return log.WithField("error_id", e.uuid).WithField("error", e.err)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// In instances where the status has already been set just use that existing status
|
||||
// since we cannot change it at this point, and trying to do so will emit a gin warning
|
||||
// into the program output.
|
||||
if c.Writer.Status() != 200 {
|
||||
status = c.Writer.Status()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on the system.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(e.err.Error(), "invalid URL escape") {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Some of the data provided in the request appears to be escaped improperly.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a Filesystem error just return it without all of the tracking code nonsense
|
||||
// since we don't need to be logging it into the logs or anything, its just a normal error
|
||||
// that the user can solve on their end.
|
||||
if st, msg := e.getAsFilesystemError(); st != 0 {
|
||||
c.AbortWithStatusJSON(st, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, log the error to zap, and then report the error back to the user.
|
||||
if status >= 500 {
|
||||
e.logger().Error("unexpected error while handling HTTP request")
|
||||
} else {
|
||||
e.logger().Debug("non-server error encountered while handling HTTP request")
|
||||
}
|
||||
|
||||
if e.message == "" {
|
||||
e.message = "An unexpected error was encountered while processing this request."
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": e.message, "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) Abort(c *gin.Context) {
|
||||
e.AbortWithStatus(http.StatusInternalServerError, c)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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 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(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 filesystem.IsErrorCode(e.err, filesystem.ErrCodeDiskSpace) || strings.Contains(e.err.Error(), "filesystem: not enough disk space") {
|
||||
return http.StatusBadRequest, "Cannot perform that action: not enough disk space available."
|
||||
}
|
||||
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 := e.err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||
return http.StatusNotFound, "The requested directory does not exist."
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Handle specific filesystem errors for a server.
|
||||
func (e *RequestError) AbortFilesystemError(c *gin.Context) {
|
||||
e.Abort(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)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
@@ -16,133 +14,8 @@ import (
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/remote"
|
||||
"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
|
||||
@@ -180,7 +53,7 @@ func AttachApiClient(client remote.Client) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
|
||||
141
router/middleware/request_error.go
Normal file
141
router/middleware/request_error.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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.ErrNotExist) ||
|
||||
filesystem.IsErrorCode(err, filesystem.ErrCodePathResolution) ||
|
||||
strings.Contains(err.Error(), "resolves to a location outside the server root") {
|
||||
return http.StatusNotFound, "The requested resources was not found on the system."
|
||||
}
|
||||
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.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, ""
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"emperror.dev/errors"
|
||||
"github.com/apex/log"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -16,7 +17,10 @@ func Configure(m *wserver.Manager, client remote.Client) *gin.Engine {
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
_ = router.SetTrustedProxies(config.Get().Api.TrustedProxies)
|
||||
if err := router.SetTrustedProxies(config.Get().Api.TrustedProxies); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
return nil
|
||||
}
|
||||
router.Use(middleware.AttachRequestID(), middleware.CaptureErrors(), middleware.SetAccessControlHeaders())
|
||||
router.Use(middleware.AttachServerManager(m), middleware.AttachApiClient(client))
|
||||
// @todo log this into a different file so you can setup IP blocking for abusive requests and such.
|
||||
|
||||
@@ -21,12 +21,11 @@ func getDownloadBackup(c *gin.Context) {
|
||||
|
||||
token := tokens.BackupPayload{}
|
||||
if err := tokens.ParseToken([]byte(c.Query("token")), &token); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
s, ok := manager.Get(token.ServerUuid)
|
||||
if !ok || !token.IsUniqueRequest() {
|
||||
if _, ok := manager.Get(token.ServerUuid); !ok || !token.IsUniqueRequest() {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on this server.",
|
||||
})
|
||||
@@ -42,13 +41,13 @@ func getDownloadBackup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(b.Path())
|
||||
if err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -57,7 +56,7 @@ func getDownloadBackup(c *gin.Context) {
|
||||
c.Header("Content-Disposition", "attachment; filename="+strconv.Quote(st.Name()))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
bufio.NewReader(f).WriteTo(c.Writer)
|
||||
_, _ = bufio.NewReader(f).WriteTo(c.Writer)
|
||||
}
|
||||
|
||||
// Handles downloading a specific file for a server.
|
||||
@@ -65,7 +64,7 @@ func getDownloadFile(c *gin.Context) {
|
||||
manager := middleware.ExtractManager(c)
|
||||
token := tokens.FilePayload{}
|
||||
if err := tokens.ParseToken([]byte(c.Query("token")), &token); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,7 +81,7 @@ func getDownloadFile(c *gin.Context) {
|
||||
// If there is an error or we're somehow trying to download a directory, just
|
||||
// respond with the appropriate error.
|
||||
if err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
} else if st.IsDir() {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
@@ -93,7 +92,7 @@ func getDownloadFile(c *gin.Context) {
|
||||
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,5 +100,5 @@ func getDownloadFile(c *gin.Context) {
|
||||
c.Header("Content-Disposition", "attachment; filename="+strconv.Quote(st.Name()))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
bufio.NewReader(f).WriteTo(c.Writer)
|
||||
_, _ = bufio.NewReader(f).WriteTo(c.Writer)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func getServerLogs(c *gin.Context) {
|
||||
|
||||
out, err := s.ReadLogfile(l)
|
||||
if err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func postServerCommands(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
|
||||
if running, err := s.Environment.IsRunning(c.Request.Context()); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
} else if !running {
|
||||
c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{
|
||||
@@ -143,7 +143,7 @@ func postServerSync(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
|
||||
if err := s.Sync(); err != nil {
|
||||
WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
} else {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -153,9 +153,15 @@ func postServerSync(c *gin.Context) {
|
||||
func postServerInstall(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
|
||||
go func(serv *server.Server) {
|
||||
if err := serv.Install(true); err != nil {
|
||||
serv.Log().WithField("error", err).Error("failed to execute server installation process")
|
||||
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)
|
||||
|
||||
@@ -211,7 +217,7 @@ func deleteServer(c *gin.Context) {
|
||||
// forcibly terminate it before removing the container, so we do not need to handle
|
||||
// that here.
|
||||
if err := s.Environment.Destroy(); err != nil {
|
||||
_ = WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func getServerListDirectory(c *gin.Context) {
|
||||
s := ExtractServer(c)
|
||||
dir := c.Query("directory")
|
||||
if stats, err := s.Filesystem().ListDirectory(dir); err != nil {
|
||||
WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -172,11 +172,11 @@ func postServerCopyFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.Filesystem().IsIgnored(data.Location); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.Filesystem().Copy(data.Location); err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ func postServerDeleteFiles(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ func postServerWriteFile(c *gin.Context) {
|
||||
f = "/" + strings.TrimLeft(f, "/")
|
||||
|
||||
if err := s.Filesystem().IsIgnored(f); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
if err := s.Filesystem().Writefile(f, c.Request.Body); err != nil {
|
||||
@@ -247,7 +247,7 @@ func postServerWriteFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,12 +294,12 @@ func postServerPullRemoteFile(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem().HasSpaceErr(true); err != nil {
|
||||
WithError(c, err)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
// Do not allow more than three simultaneous remote file downloads at one time.
|
||||
@@ -338,13 +338,13 @@ func postServerPullRemoteFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := download(); err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
st, err := s.Filesystem().Stat(dl.Path())
|
||||
if err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, &st)
|
||||
@@ -380,7 +380,7 @@ func postServerCreateDirectory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func postServerCompressFiles(c *gin.Context) {
|
||||
|
||||
f, err := s.Filesystem().CompressFiles(data.RootPath, data.Files)
|
||||
if err != nil {
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ func postServerChmodFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
NewServerError(err, s).AbortFilesystemError(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,7 +545,7 @@ func postServerUploadFiles(c *gin.Context) {
|
||||
|
||||
token := tokens.UploadPayload{}
|
||||
if err := tokens.ParseToken([]byte(c.Query("token")), &token); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -591,14 +591,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).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
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).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
} else {
|
||||
s.SaveActivity(s.NewRequestActivity(token.UserUuid, c.ClientIP()), server.ActivityFileUploaded, models.ActivityMeta{
|
||||
|
||||
@@ -34,7 +34,7 @@ func getServerWebsocket(c *gin.Context) {
|
||||
|
||||
handler, err := websocket.GetHandler(s, c.Writer, c.Request, c)
|
||||
if err != nil {
|
||||
NewServerError(err, s).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
defer handler.Connection.Close()
|
||||
|
||||
@@ -20,12 +20,28 @@ import (
|
||||
func getSystemInformation(c *gin.Context) {
|
||||
i, err := system.GetSystemInformation()
|
||||
if err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("v") == "2" {
|
||||
c.JSON(http.StatusOK, i)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, struct {
|
||||
Architecture string `json:"architecture"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
OS string `json:"os"`
|
||||
Version string `json:"version"`
|
||||
}{
|
||||
Architecture: i.System.Architecture,
|
||||
CPUCount: i.System.CPUThreads,
|
||||
KernelVersion: i.System.KernelVersion,
|
||||
OS: i.System.OSType,
|
||||
Version: i.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// Returns all the servers that are registered and configured correctly on
|
||||
@@ -75,7 +91,7 @@ func postCreateServer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := i.Server().Install(false); err != nil {
|
||||
if err := i.Server().Install(); err != nil {
|
||||
log.WithFields(log.Fields{"server": i.Server().ID(), "error": err}).Error("failed to run install process for server")
|
||||
return
|
||||
}
|
||||
@@ -117,7 +133,7 @@ func postUpdateConfiguration(c *gin.Context) {
|
||||
// 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)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
// Since we wrote it to the disk successfully now update the global configuration
|
||||
|
||||
@@ -38,14 +38,14 @@ func postTransfers(c *gin.Context) {
|
||||
|
||||
token := tokens.TransferPayload{}
|
||||
if err := tokens.ParseToken([]byte(auth[1]), &token); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
manager := middleware.ExtractManager(c)
|
||||
u, err := uuid.Parse(token.Subject)
|
||||
if err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func postTransfers(c *gin.Context) {
|
||||
if err := manager.Client().SetTransferStatus(context.Background(), trnsfr.Server.ID(), false); err != nil {
|
||||
trnsfr.Log().WithField("status", false).WithError(err).Error("failed to set transfer status")
|
||||
}
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,13 +123,13 @@ func postTransfers(c *gin.Context) {
|
||||
mediaType, params, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
|
||||
if err != nil {
|
||||
trnsfr.Log().Debug("failed to parse content type header")
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
trnsfr.Log().Debug("invalid content type")
|
||||
NewTrackedError(fmt.Errorf("invalid content type \"%s\", expected \"multipart/form-data\"", mediaType)).Abort(c)
|
||||
middleware.CaptureAndAbort(c, fmt.Errorf("invalid content type \"%s\", expected \"multipart/form-data\"", mediaType))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ out:
|
||||
break out
|
||||
}
|
||||
if err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -166,13 +166,13 @@ out:
|
||||
trnsfr.Log().Debug("received archive")
|
||||
|
||||
if err := trnsfr.Server.EnsureDataDirectoryExists(); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tee := io.TeeReader(p, h)
|
||||
if err := trnsfr.Server.Filesystem().ExtractStreamUnsafe(ctx, "/", tee); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ out:
|
||||
trnsfr.Log().Debug("received checksum")
|
||||
|
||||
if !hasArchive {
|
||||
NewTrackedError(errors.New("archive must be sent before the checksum")).Abort(c)
|
||||
middleware.CaptureAndAbort(c, errors.New("archive must be sent before the checksum"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,14 +189,14 @@ out:
|
||||
|
||||
v, err := io.ReadAll(p)
|
||||
if err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
expected := make([]byte, hex.DecodedLen(len(v)))
|
||||
n, err := hex.Decode(expected, v)
|
||||
if err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
actual := h.Sum(nil)
|
||||
@@ -207,7 +207,7 @@ out:
|
||||
}).Debug("checksums")
|
||||
|
||||
if !bytes.Equal(expected[:n], actual) {
|
||||
NewTrackedError(errors.New("checksums don't match")).Abort(c)
|
||||
middleware.CaptureAndAbort(c, errors.New("checksums don't match"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -220,12 +220,12 @@ out:
|
||||
}
|
||||
|
||||
if !hasArchive || !hasChecksum {
|
||||
NewTrackedError(errors.New("missing archive or checksum")).Abort(c)
|
||||
middleware.CaptureAndAbort(c, errors.New("missing archive or checksum"))
|
||||
return
|
||||
}
|
||||
|
||||
if !checksumVerified {
|
||||
NewTrackedError(errors.New("checksums don't match")).Abort(c)
|
||||
middleware.CaptureAndAbort(c, errors.New("checksums don't match"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ out:
|
||||
|
||||
// Ensure the server environment gets configured.
|
||||
if err := trnsfr.Server.CreateEnvironment(); err != nil {
|
||||
NewTrackedError(err).Abort(c)
|
||||
middleware.CaptureAndAbort(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
ErrCodePathResolution ErrorCode = "E_BADPATH"
|
||||
ErrCodeDenylistFile ErrorCode = "E_DENYLIST"
|
||||
ErrCodeUnknownError ErrorCode = "E_UNKNOWN"
|
||||
ErrNotExist ErrorCode = "E_NOTEXIST"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
@@ -68,6 +69,8 @@ func (e *Error) Error() string {
|
||||
r = "<empty>"
|
||||
}
|
||||
return fmt.Sprintf("filesystem: server path [%s] resolves to a location outside the server root: %s", e.path, r)
|
||||
case ErrNotExist:
|
||||
return "filesystem: does not exist"
|
||||
case ErrCodeUnknownError:
|
||||
fallthrough
|
||||
default:
|
||||
|
||||
@@ -61,25 +61,28 @@ func (fs *Filesystem) Path() string {
|
||||
func (fs *Filesystem) File(p string) (*os.File, Stat, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, Stat{}, err
|
||||
return nil, Stat{}, errors.WithStackIf(err)
|
||||
}
|
||||
st, err := fs.Stat(cleaned)
|
||||
if err != nil {
|
||||
return nil, Stat{}, err
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, Stat{}, newFilesystemError(ErrNotExist, err)
|
||||
}
|
||||
return nil, Stat{}, errors.WithStackIf(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, Stat{}, newFilesystemError(ErrCodeIsDirectory, nil)
|
||||
}
|
||||
f, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
return nil, Stat{}, err
|
||||
return nil, Stat{}, errors.WithStackIf(err)
|
||||
}
|
||||
return f, st, nil
|
||||
}
|
||||
|
||||
// Acts by creating the given file and path on the disk if it is not present already. If
|
||||
// it is present, the file is opened using the defaults which will truncate the contents.
|
||||
// The opened file is then returned to the caller.
|
||||
// Touch acts by creating the given file and path on the disk if it is not present
|
||||
// already. If it is present, the file is opened using the defaults which will truncate
|
||||
// the contents. The opened file is then returned to the caller.
|
||||
func (fs *Filesystem) Touch(p string, flag int) (*os.File, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
|
||||
@@ -84,6 +84,35 @@ func (rfs *rootFs) reset() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystem_Openfile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("File", func() {
|
||||
g.It("returns custom error when file does not exist", func() {
|
||||
_, _, err := fs.File("foo/bar.txt")
|
||||
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(IsErrorCode(err, ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns file stat information", func() {
|
||||
_ = rfs.CreateServerFile("foo.txt", []byte("hello world"))
|
||||
|
||||
f, st, err := fs.File("foo.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
g.Assert(st.Name()).Equal("foo.txt")
|
||||
g.Assert(f).IsNotNil()
|
||||
_ = f.Close()
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
rfs.reset()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Writefile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
@@ -32,19 +32,17 @@ import (
|
||||
//
|
||||
// Pass true as the first argument in order to execute a server sync before the
|
||||
// process to ensure the latest information is used.
|
||||
func (s *Server) Install(sync bool) error {
|
||||
if sync {
|
||||
s.Log().Info("syncing server state with remote source before executing installation process")
|
||||
if err := s.Sync(); err != nil {
|
||||
return errors.WrapIf(err, "install: failed to sync server state with Panel")
|
||||
}
|
||||
}
|
||||
func (s *Server) Install() error {
|
||||
return s.install(false)
|
||||
}
|
||||
|
||||
func (s *Server) install(reinstall bool) error {
|
||||
var err error
|
||||
if !s.Config().SkipEggScripts {
|
||||
// Send the start event so the Panel can automatically update. We don't send this unless the process
|
||||
// is actually going to run, otherwise all sorts of weird rapid UI behavior happens since there isn't
|
||||
// an actual install process being executed.
|
||||
// Send the start event so the Panel can automatically update. We don't
|
||||
// send this unless the process is actually going to run, otherwise all
|
||||
// sorts of weird rapid UI behavior happens since there isn't an actual
|
||||
// install process being executed.
|
||||
s.Events().Publish(InstallStartedEvent, "")
|
||||
|
||||
err = s.internalInstall()
|
||||
@@ -53,12 +51,13 @@ func (s *Server) Install(sync bool) error {
|
||||
}
|
||||
|
||||
s.Log().WithField("was_successful", err == nil).Debug("notifying panel of server install state")
|
||||
if serr := s.SyncInstallState(err == nil); serr != nil {
|
||||
if serr := s.SyncInstallState(err == nil, reinstall); serr != nil {
|
||||
l := s.Log().WithField("was_successful", err == nil)
|
||||
|
||||
// If the request was successful but there was an error with this request, attach the
|
||||
// error to this log entry. Otherwise ignore it in this log since whatever is calling
|
||||
// this function should handle the error and will end up logging the same one.
|
||||
// If the request was successful but there was an error with this request,
|
||||
// attach the error to this log entry. Otherwise, ignore it in this log
|
||||
// since whatever is calling this function should handle the error and
|
||||
// will end up logging the same one.
|
||||
if err == nil {
|
||||
l.WithField("error", err)
|
||||
}
|
||||
@@ -66,19 +65,20 @@ func (s *Server) Install(sync bool) error {
|
||||
l.Warn("failed to notify panel of server install state")
|
||||
}
|
||||
|
||||
// Ensure that the server is marked as offline at this point, otherwise you end up
|
||||
// with a blank value which is a bit confusing.
|
||||
// Ensure that the server is marked as offline at this point, otherwise you
|
||||
// end up with a blank value which is a bit confusing.
|
||||
s.Environment.SetState(environment.ProcessOfflineState)
|
||||
|
||||
// Push an event to the websocket so we can auto-refresh the information in the panel once
|
||||
// the install is completed.
|
||||
// Push an event to the websocket, so we can auto-refresh the information in
|
||||
// the panel once the installation is completed.
|
||||
s.Events().Publish(InstallCompletedEvent, "")
|
||||
|
||||
return errors.WithStackIf(err)
|
||||
}
|
||||
|
||||
// Reinstalls a server's software by utilizing the install script for the server egg. This
|
||||
// does not touch any existing files for the server, other than what the script modifies.
|
||||
// Reinstall reinstalls a server's software by utilizing the installation script
|
||||
// for the server egg. This does not touch any existing files for the server,
|
||||
// other than what the script modifies.
|
||||
func (s *Server) Reinstall() error {
|
||||
if s.Environment.State() != environment.ProcessOfflineState {
|
||||
s.Log().Debug("waiting for server instance to enter a stopped state")
|
||||
@@ -87,7 +87,12 @@ func (s *Server) Reinstall() error {
|
||||
}
|
||||
}
|
||||
|
||||
return s.Install(true)
|
||||
s.Log().Info("syncing server state with remote source before executing re-installation process")
|
||||
if err := s.Sync(); err != nil {
|
||||
return errors.WrapIf(err, "install: failed to sync server state with Panel")
|
||||
}
|
||||
|
||||
return s.install(true)
|
||||
}
|
||||
|
||||
// Internal installation function used to simplify reporting back to the Panel.
|
||||
@@ -116,8 +121,9 @@ type InstallationProcess struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
// Generates a new installation process struct that will be used to create containers,
|
||||
// and otherwise perform installation commands for a server.
|
||||
// NewInstallationProcess returns a new installation process struct that will be
|
||||
// used to create containers and otherwise perform installation commands for a
|
||||
// server.
|
||||
func NewInstallationProcess(s *Server, script *remote.InstallationScript) (*InstallationProcess, error) {
|
||||
proc := &InstallationProcess{
|
||||
Script: script,
|
||||
@@ -133,8 +139,8 @@ func NewInstallationProcess(s *Server, script *remote.InstallationScript) (*Inst
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
// Determines if the server is actively running the installation process by checking the status
|
||||
// of the installer lock.
|
||||
// IsInstalling returns if the server is actively running the installation
|
||||
// process by checking the status of the installer lock.
|
||||
func (s *Server) IsInstalling() bool {
|
||||
return s.installing.Load()
|
||||
}
|
||||
@@ -155,7 +161,7 @@ func (s *Server) SetRestoring(state bool) {
|
||||
s.restoring.Store(state)
|
||||
}
|
||||
|
||||
// Removes the installer container for the server.
|
||||
// RemoveContainer removes the installation container for the server.
|
||||
func (ip *InstallationProcess) RemoveContainer() error {
|
||||
err := ip.client.ContainerRemove(ip.Server.Context(), ip.Server.ID()+"_installer", types.ContainerRemoveOptions{
|
||||
RemoveVolumes: true,
|
||||
@@ -328,14 +334,14 @@ func (ip *InstallationProcess) BeforeExecute() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns the log path for the installation process.
|
||||
// GetLogPath returns the log path for the installation process.
|
||||
func (ip *InstallationProcess) GetLogPath() string {
|
||||
return filepath.Join(config.Get().System.LogDirectory, "/install", ip.Server.ID()+".log")
|
||||
}
|
||||
|
||||
// Cleans up after the execution of the installation process. This grabs the logs from the
|
||||
// process to store in the server configuration directory, and then destroys the associated
|
||||
// installation container.
|
||||
// AfterExecute cleans up after the execution of the installation process.
|
||||
// This grabs the logs from the process to store in the server configuration
|
||||
// directory, and then destroys the associated installation container.
|
||||
func (ip *InstallationProcess) AfterExecute(containerId string) error {
|
||||
defer ip.RemoveContainer()
|
||||
|
||||
@@ -525,7 +531,7 @@ func (ip *InstallationProcess) StreamOutput(ctx context.Context, id string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// resourceLimits returns the install container specific resource limits. This
|
||||
// resourceLimits returns resource limits for the installation container. This
|
||||
// looks at the globally defined install container limits and attempts to use
|
||||
// the higher of the two (defined limits & server limits). This allows for servers
|
||||
// with super low limits (e.g. Discord bots with 128Mb of memory) to perform more
|
||||
@@ -537,8 +543,8 @@ func (ip *InstallationProcess) StreamOutput(ctx context.Context, id string) erro
|
||||
func (ip *InstallationProcess) resourceLimits() container.Resources {
|
||||
limits := config.Get().Docker.InstallerLimits
|
||||
|
||||
// Create a copy of the configuration so we're not accidentally making changes
|
||||
// to the underlying server build data.
|
||||
// Create a copy of the configuration, so we're not accidentally making
|
||||
// changes to the underlying server build data.
|
||||
c := *ip.Server.Config()
|
||||
cfg := c.Build
|
||||
if cfg.MemoryLimit < limits.Memory {
|
||||
@@ -562,10 +568,12 @@ func (ip *InstallationProcess) resourceLimits() container.Resources {
|
||||
return resources
|
||||
}
|
||||
|
||||
// SyncInstallState makes a HTTP request to the Panel instance notifying it that
|
||||
// SyncInstallState makes an HTTP request to the Panel instance notifying it that
|
||||
// the server has completed the installation process, and what the state of the
|
||||
// server is. A boolean value of "true" means everything was successful, "false"
|
||||
// means something went wrong and the server must be deleted and re-created.
|
||||
func (s *Server) SyncInstallState(successful bool) error {
|
||||
return s.client.SetInstallationStatus(s.Context(), s.ID(), successful)
|
||||
// server is.
|
||||
func (s *Server) SyncInstallState(successful, reinstall bool) error {
|
||||
return s.client.SetInstallationStatus(s.Context(), s.ID(), remote.InstallStatusRequest{
|
||||
Successful: successful,
|
||||
Reinstall: reinstall,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package system
|
||||
|
||||
var Version = "1.11.0-rc.1"
|
||||
var Version = "develop"
|
||||
|
||||
128
system/system.go
128
system/system.go
@@ -1,17 +1,57 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
"github.com/acobaugh/osrelease"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/parsers/kernel"
|
||||
)
|
||||
|
||||
type Information struct {
|
||||
Version string `json:"version"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
Docker DockerInformation `json:"docker"`
|
||||
System System `json:"system"`
|
||||
}
|
||||
|
||||
type DockerInformation struct {
|
||||
Version string `json:"version"`
|
||||
Cgroups DockerCgroups `json:"cgroups"`
|
||||
Containers DockerContainers `json:"containers"`
|
||||
Storage DockerStorage `json:"storage"`
|
||||
Runc DockerRunc `json:"runc"`
|
||||
}
|
||||
|
||||
type DockerCgroups struct {
|
||||
Driver string `json:"driver"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type DockerContainers struct {
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
Paused int `json:"paused"`
|
||||
Stopped int `json:"stopped"`
|
||||
}
|
||||
|
||||
type DockerStorage struct {
|
||||
Driver string `json:"driver"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
}
|
||||
|
||||
type DockerRunc struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type System struct {
|
||||
Architecture string `json:"architecture"`
|
||||
CPUThreads int `json:"cpu_threads"`
|
||||
MemoryBytes int64 `json:"memory_bytes"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
OS string `json:"os"`
|
||||
CpuCount int `json:"cpu_count"`
|
||||
OSType string `json:"os_type"`
|
||||
}
|
||||
|
||||
func GetSystemInformation() (*Information, error) {
|
||||
@@ -20,13 +60,83 @@ func GetSystemInformation() (*Information, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Information{
|
||||
Version: Version,
|
||||
KernelVersion: k.String(),
|
||||
Architecture: runtime.GOARCH,
|
||||
OS: runtime.GOOS,
|
||||
CpuCount: runtime.NumCPU(),
|
||||
version, info, err := GetDockerInfo(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
release, err := osrelease.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var os string
|
||||
if release["PRETTY_NAME"] != "" {
|
||||
os = release["PRETTY_NAME"]
|
||||
} else if release["NAME"] != "" {
|
||||
os = release["NAME"]
|
||||
} else {
|
||||
os = info.OperatingSystem
|
||||
}
|
||||
|
||||
var filesystem string
|
||||
for _, v := range info.DriverStatus {
|
||||
if v[0] != "Backing Filesystem" {
|
||||
continue
|
||||
}
|
||||
filesystem = v[1]
|
||||
break
|
||||
}
|
||||
|
||||
return &Information{
|
||||
Version: Version,
|
||||
Docker: DockerInformation{
|
||||
Version: version.Version,
|
||||
Cgroups: DockerCgroups{
|
||||
Driver: info.CgroupDriver,
|
||||
Version: info.CgroupVersion,
|
||||
},
|
||||
Containers: DockerContainers{
|
||||
Total: info.Containers,
|
||||
Running: info.ContainersRunning,
|
||||
Paused: info.ContainersPaused,
|
||||
Stopped: info.ContainersStopped,
|
||||
},
|
||||
Storage: DockerStorage{
|
||||
Driver: info.Driver,
|
||||
Filesystem: filesystem,
|
||||
},
|
||||
Runc: DockerRunc{
|
||||
Version: info.RuncCommit.ID,
|
||||
},
|
||||
},
|
||||
System: System{
|
||||
Architecture: runtime.GOARCH,
|
||||
CPUThreads: runtime.NumCPU(),
|
||||
MemoryBytes: info.MemTotal,
|
||||
KernelVersion: k.String(),
|
||||
OS: os,
|
||||
OSType: runtime.GOOS,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GetDockerInfo(ctx context.Context) (types.Version, types.Info, error) {
|
||||
// TODO: find a way to re-use the client from the docker environment.
|
||||
c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return types.Version{}, types.Info{}, err
|
||||
}
|
||||
|
||||
dockerVersion, err := c.ServerVersion(ctx)
|
||||
if err != nil {
|
||||
return types.Version{}, types.Info{}, err
|
||||
}
|
||||
|
||||
dockerInfo, err := c.Info(ctx)
|
||||
if err != nil {
|
||||
return types.Version{}, types.Info{}, err
|
||||
}
|
||||
|
||||
return dockerVersion, dockerInfo, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user