Compare commits

...

49 Commits

Author SHA1 Message Date
Matthew Penner
dafbbab2ed metrics: initial commit 2021-06-22 08:17:02 -06:00
Dane Everitt
08a7ccd175 Update CHANGELOG.md 2021-06-20 18:07:20 -07:00
Dane Everitt
8336f6ff29 Apply container limits to install containers, defaulting to minimums if the server's resources are set too low 2021-06-20 17:21:51 -07:00
Dane Everitt
e0078eee0a [security] enforce process limits at a per-container level to avoid abusive clients impacting other instances 2021-06-20 16:54:00 -07:00
Dane Everitt
c0063d2c61 Update CHANGELOG.md 2021-06-05 08:50:26 -07:00
Dane Everitt
f74a74cd5e Merge pull request #93 from JulienTant/develop
Add decompress tests
2021-06-05 08:46:14 -07:00
Dane Everitt
8055d1355d Update CHANGELOG.md 2021-05-02 15:52:34 -07:00
Dane Everitt
c1ff32ad32 Update test based on corrected error response logic 2021-05-02 15:43:22 -07:00
Dane Everitt
49dd1f7bde Better support for retrying failed requests with the API
Also implements more logic error returns from the Get/Post functions in the client, rather than making the developer call r.Error() on responses.
2021-05-02 15:41:02 -07:00
Dane Everitt
3f47bfd292 Add backoff retries to API calls from Wings 2021-05-02 15:16:30 -07:00
Dane Everitt
ddfd6d9cce Modify backup process to utilize contexts and exponential backoffs
If a request to upload a file part to S3 fails for any 5xx reason it will begin using an exponential backoff to keep re-trying the upload until we've reached a minute of trying to access the endpoint.

This should resolve temporary resolution issues with URLs and certain S3 compatiable systems such as B2 that sometimes return a 5xx error and just need a retry to be successful.

Also supports using the server context to ensure backups are terminated when a server is deleted, and removes the http call without a timeout, replacing it with a 2 hour timeout to account for connections as slow as 10Mbps on a huge file upload.
2021-05-02 12:28:36 -07:00
Dane Everitt
da74ac8291 Trim "~" from container prefix; closes pterodactyl/panel#3310 2021-05-02 11:00:10 -07:00
Dane Everitt
3fda548541 Update CHANGELOG.md 2021-04-27 19:07:31 -07:00
Julien Tant
35b2c420ec add decompress tests 2021-04-25 16:44:54 -07:00
Dane Everitt
daaef5044e Correctly determine name for archive files when decompressing; closes pterodactyl/panel#3296 2021-04-25 15:36:00 -07:00
Dane Everitt
35ba6d7524 Update CHANGELOG.md 2021-04-24 16:52:19 -07:00
Dane Everitt
fb0e769306 fix error when out of disk space; closes pterodactyl/panel#3273 2021-04-18 14:48:42 -07:00
Dane Everitt
0676a82a21 Add better error handling for filesystem 2021-04-17 13:29:18 -07:00
Dane Everitt
a0ae5fd131 Merge branch 'develop' of github.com:pterodactyl/wings into develop 2021-04-17 13:13:40 -07:00
Dane Everitt
4b244e96fb Fix .rar file decompression; closes pterodactyl/panel#3267 2021-04-17 13:13:37 -07:00
Dane Everitt
488884fdee Merge pull request #92 from parkervcp/fix_docker_build
Fixes ghcr build
2021-04-13 08:18:30 -07:00
Michael Parker
cfa338108f Fixes ghcr build
Removes version pins so packages install properly.
2021-04-12 19:38:16 -04:00
Dane Everitt
16b0ca3a8e Use io#LimitReader to avoid panic when reading files with active writes; closes pterodactyl/panel#3131 2021-04-04 10:42:03 -07:00
Dane Everitt
f57c24002e More API response fixing 2021-04-04 10:20:27 -07:00
Dane Everitt
8dfd494eaf Better explain what is happening in this file 2021-04-03 14:16:00 -07:00
Dane Everitt
2e0496c1f9 Add note about handling of UTF-8 sequences in properties files. 2021-04-03 14:02:37 -07:00
Dane Everitt
f85ee1aa73 cleanup 2021-04-03 13:20:07 -07:00
Dane Everitt
d4b63bef39 Fix details fetching for a single server instance 2021-04-03 13:15:11 -07:00
Dane Everitt
4c3b497652 Better error handling and reporting for image pull errors 2021-04-03 12:52:32 -07:00
Dane Everitt
ff62d16085 Merge branch 'develop' of github.com:pterodactyl/wings into develop 2021-04-03 11:18:44 -07:00
Dane Everitt
202ca922ad Update README.md 2021-04-03 11:18:33 -07:00
Dane Everitt
76b7967fef Merge pull request #88 from Antony1060/develop
Added app name
2021-04-03 11:13:29 -07:00
Dane Everitt
1b1eaa3171 Avoid expensive copies of the config for every line output 2021-04-03 11:11:36 -07:00
Dane Everitt
87f0b11078 Merge pull request #90 from Antony1060/fix
Fixed /api/servers
2021-04-03 11:08:43 -07:00
Dane Everitt
b448310a33 Correctly return servers installed on wings and their resource usage 2021-04-03 11:08:26 -07:00
Dane Everitt
f1b85ef0ab Merge pull request #91 from nysos3/develop
Fix reading User.Gid from WINGS_GID over WINGS_UID
2021-04-03 09:03:10 -07:00
Cody Carrell
bec6a6112d Fix reading User.Gid from WINGS_GID over WINGS_UID 2021-04-02 22:45:56 -04:00
antony1060
b691b8f06f Fixed /api/servers 2021-04-02 21:32:30 +02:00
Dane Everitt
31127620e5 License date updates 2021-03-26 09:33:24 -07:00
Dane Everitt
5e7316e09a Update CHANGELOG.md 2021-03-26 09:13:38 -07:00
Antony
52fcf1e37f Added defaults
Co-authored-by: Jakob <dev@schrej.net>
2021-03-24 11:24:54 +01:00
antony1060
0c17e240f4 Added app name 2021-03-24 10:26:03 +01:00
Matthew Penner
471886dd34 internally mark if a server is restoring to restrict actions 2021-03-12 16:19:35 -07:00
Dane Everitt
b63a491b5e Update CHANGELOG.md 2021-03-07 17:37:03 -08:00
Dane Everitt
6902422229 Update CHANGELOG.md 2021-03-07 17:35:55 -08:00
Dane Everitt
5f5b2bc84e Quick little code cleanup and adding some commentary 2021-03-07 17:31:45 -08:00
Dane Everitt
81a411a42c Merge branch 'develop' of github.com:pterodactyl/wings into develop 2021-03-07 17:24:47 -08:00
Dane Everitt
37c6b85489 Don't throw back errors on termination if the container doesn't exist; closes pterodactyl/panel#3149 2021-03-07 17:24:45 -08:00
Matthew Penner
0e3778ac47 transfers: use backup archiver 2021-03-07 11:04:15 -07:00
57 changed files with 1367 additions and 885 deletions

View File

@@ -1,5 +1,53 @@
# Changelog
## v1.4.4
### Added
* **[security]** Adds support for limiting the total number of pids any one container can have active at once to prevent malicious users from impacting other instances on the same node.
* Server install containers now use the limits assigned to the server, or a globally defined minimum amount of memory and CPU rather than having unlimited resources.
## v1.4.3
This build was created to address `CVE-2021-33196` in `Go` which requires a new binary
be built on the latest `go1.15` version.
## v1.4.2
### Fixed
* Fixes the `~` character not being properly trimmed from container image names when creating a new server.
### Changed
* Implemented exponential backoff for S3 uploads when working with backups. This should resolve many issues with external S3 compatiable systems that sometimes return 5xx level errors that should be re-attempted automatically.
* Implements exponential backoff behavior for all API calls to the Panel that do not immediately return a 401, 403, or 429 error response. This should address fragiligty in some API calls and address random call failures due to connection drops or random DNS resolution errors.
## v1.4.1
### Fixed
* Fixes a bug that would cause the file unarchiving process to put all files in the base directory rather than the directory in which the files should be located.
## v1.4.0
### Fixed
* **[Breaking]** Fixes `/api/servers` and `/api/servers/:server` not properly returning all of the relevant server information and resource usage.
* Fixes Wings improperly reading `WINGS_UID` and not `WINGS_GID` when running in containerized environments.
* Fixes a panic encountered when returning the contents of a file that is actively being written to by another process.
* Corrected the handling of files that are being decompressed to properly support `.rar` files.
* Fixes the error message returned when a server has run out of disk space to properly indicate such, rather than indicating that the file is a directory.
### Changed
* Improved the error handling and output when an error is encountered while pulling an image for a server.
* Improved robustness of code handling value replacement in configuration files to not potentially panic if a non-string value is encountered as the replacement type.
* Improves error handling throughout the server filesystem.
### Added
* Adds the ability to set the internal name of the application in response output from the console using the `app_name` key in the `config.yml` file.
## v1.3.2
### Fixed
* Correctly sets the internal state of the server as restoring when a restore is being performed to avoid any accidental booting.
## v1.3.1
### Fixed
* Fixes an error being returned to the client when attempting to restart a server when the container no longer exists on the machine.
### Changed
* Updated server transfer logic to use newer file archiving tools to avoid frequent errors when transferring symlinked files.
## v1.3.0
### Fixed
* Fixes improper error handling when attempting to create a new Docker network.

View File

@@ -2,7 +2,7 @@
FROM golang:1.15-alpine3.12 AS builder
ARG VERSION
RUN apk add --update --no-cache git=2.26.2-r0 make=4.3-r0 upx=3.96-r0
RUN apk add --update --no-cache git make upx
WORKDIR /app/
COPY go.mod go.sum /app/
RUN go mod download

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 Dane Everitt <dane@daneeveritt.com>
Copyright (c) 2018 - 2021 Dane Everitt <dane@daneeveritt.com> and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -19,14 +19,19 @@ I would like to extend my sincere thanks to the following sponsors for helping f
| Company | About |
| ------- | ----- |
| [**WISP**](https://wisp.gg) | Extra features. |
| [**MixmlHosting**](https://mixmlhosting.com) | MixmlHosting provides high quality Virtual Private Servers along with game servers, all at a affordable price. |
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
| [**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. |
| [**MineStrator**](https://minestrator.com/) | Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord, trust us. |
| [**DedicatedMC**](https://dedicatedmc.io/) | DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance and giving you the best performance money can buy. |
| [**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! |
| [**XCORE**](https://xcore-server.de/) | XCORE offers High-End Servers for hosting and gaming since 2012. Fast, excellent and well-known for eSports Gaming. |
| [**RoyaleHosting**](https://royalehosting.net/) | Build your dreams and deploy them with RoyaleHostings reliable servers and network. Easy to use, provisioned in a couple of minutes. |
| [**Spill Hosting**](https://spillhosting.no/) | Spill Hosting is a Norwegian hosting service, which aims to cheap services on quality servers. Premium i9-9900K processors will run your game like a dream. |
| [**Spill Hosting**](https://spillhosting.no/) | Spill Hosting is a Norwegian hosting service, which aims for inexpensive services on quality servers. Premium i9-9900K processors will run your game like a dream. |
| [**DeinServerHost**](https://deinserverhost.de/) | DeinServerHost offers Dedicated, vps and Gameservers for many popular Games like Minecraft and Rust in Germany since 2013. |
| [**HostBend**](https://hostbend.com/) | HostBend offers a variety of solutions for developers, students, and others who have a tight budget but don't want to compromise quality and support. |
| [**Capitol Hosting Solutions**](https://capitolsolutions.cloud/) | CHS is *the* budget friendly hosting company for Australian and American gamers, offering a variety of plans from Web Hosting to Game Servers; Custom Solutions too! |
| [**ByteAnia**](https://byteania.com/?utm_source=pterodactyl) | ByteAnia offers the best performing and most affordable **Ryzen 5000 Series hosting** on the market for *unbeatable prices*! |
## Documentation
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)

View File

@@ -4,6 +4,7 @@ import (
"crypto/tls"
"errors"
"fmt"
"github.com/pterodactyl/wings/metrics"
log2 "log"
"net/http"
"os"
@@ -58,7 +59,7 @@ var versionCommand = &cobra.Command{
Use: "version",
Short: "Prints the current executable version and exits.",
Run: func(cmd *cobra.Command, _ []string) {
fmt.Printf("wings v%s\nCopyright © 2018 - 2021 Dane Everitt & Contributors\n", system.Version)
fmt.Printf("wings v%s\nCopyright © 2018 - %d Dane Everitt & Contributors\n", system.Version, time.Now().Year())
},
}
@@ -137,6 +138,9 @@ func rootCmdRun(cmd *cobra.Command, _ []string) {
"gid": config.Get().System.User.Gid,
}).Info("configured system user successfully")
done := make(chan bool)
go metrics.Initialize(done)
pclient := remote.New(
config.Get().PanelLocation,
remote.WithCredentials(config.Get().AuthenticationTokenId, config.Get().AuthenticationToken),
@@ -199,6 +203,12 @@ func rootCmdRun(cmd *cobra.Command, _ []string) {
continue
}
if states[s.Id()] == environment.ProcessRunningState {
metrics.ServerStatus.WithLabelValues(s.Id()).Set(1)
} else {
metrics.ServerStatus.WithLabelValues(s.Id()).Set(0)
}
pool.Submit(func() {
s.Log().Info("configuring server environment and restoring to previous state")
var st string
@@ -346,6 +356,7 @@ func rootCmdRun(cmd *cobra.Command, _ []string) {
if err := s.ListenAndServe(); err != nil {
log.WithField("error", err).Fatal("failed to configure HTTP server")
}
<-done
}
// Reads the configuration from the disk and then sets up the global singleton
@@ -400,7 +411,7 @@ __ [blue][bold]Pterodactyl[reset] _____/___/_______ _______ ______
\___/\___/___/___/___/___ /______/
/_______/ [bold]%s[reset]
Copyright © 2018 - 2021 Dane Everitt & Contributors
Copyright © 2018 - %d Dane Everitt & Contributors
Website: https://pterodactyl.io
Source: https://github.com/pterodactyl/wings
@@ -408,7 +419,7 @@ License: https://github.com/pterodactyl/wings/blob/develop/LICENSE
This software is made available under the terms of the MIT license.
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.%s`), system.Version, "\n\n")
in all copies or substantial portions of the Software.%s`), system.Version, time.Now().Year(), "\n\n")
}
func exitWithConfigurationNotice() {

View File

@@ -91,6 +91,12 @@ type ApiConfiguration struct {
UploadLimit int `default:"100" json:"upload_limit" yaml:"upload_limit"`
}
// MetricsConfiguration .
type MetricsConfiguration struct {
// Bind .
Bind string `default:":9000" yaml:"bind"`
}
// RemoteQueryConfiguration defines the configuration settings for remote requests
// from Wings to the Panel.
type RemoteQueryConfiguration struct {
@@ -247,6 +253,8 @@ type Configuration struct {
// if the debug flag is passed through the command line arguments.
Debug bool
AppName string `default:"Pterodactyl" json:"app_name" yaml:"app_name"`
// A unique identifier for this node in the Panel.
Uuid string
@@ -261,6 +269,7 @@ type Configuration struct {
Api ApiConfiguration `json:"api" yaml:"api"`
System SystemConfiguration `json:"system" yaml:"system"`
Docker DockerConfiguration `json:"docker" yaml:"docker"`
Metrics MetricsConfiguration `json:"metrics" yaml:"metrics"`
// Defines internal throttling configurations for server processes to prevent
// someone from running an endless loop that spams data to logs.
@@ -395,7 +404,7 @@ func EnsurePterodactylUser() error {
if sysName == "busybox" {
_config.System.Username = system.FirstNotEmpty(os.Getenv("WINGS_USERNAME"), "pterodactyl")
_config.System.User.Uid = system.MustInt(system.FirstNotEmpty(os.Getenv("WINGS_UID"), "988"))
_config.System.User.Gid = system.MustInt(system.FirstNotEmpty(os.Getenv("WINGS_UID"), "988"))
_config.System.User.Gid = system.MustInt(system.FirstNotEmpty(os.Getenv("WINGS_GID"), "988"))
return nil
}

View File

@@ -55,6 +55,21 @@ type DockerConfiguration struct {
// utilizes host memory for this value, and that we do not keep track of the space used here
// so avoid allocating too much to a server.
TmpfsSize uint `default:"100" json:"tmpfs_size" yaml:"tmpfs_size"`
// ContainerPidLimit sets the total number of processes that can be active in a container
// at any given moment. This is a security concern in shared-hosting environments where a
// malicious process could create enough processes to cause the host node to run out of
// available pids and crash.
ContainerPidLimit int64 `default:"256" json:"container_pid_limit" yaml:"container_pid_limit"`
// InstallLimits defines the limits on the installer containers that prevents a server's
// installation process from unintentionally consuming more resources than expected. This
// is used in conjunction with the server's defined limits. Whichever value is higher will
// take precedence in the install containers.
InstallerLimits struct {
Memory int64 `default:"1024" json:"memory" yaml:"memory"`
Cpu int64 `default:"100" json:"cpu" yaml:"cpu"`
} `json:"installer_limits" yaml:"installer_limits"`
}
// RegistryConfiguration defines the authentication credentials for a given

View File

@@ -3,9 +3,14 @@ package docker
import (
"bufio"
"context"
"emperror.dev/errors"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -15,16 +20,9 @@ import (
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/environment"
"github.com/pterodactyl/wings/system"
"io"
"strconv"
"strings"
"time"
)
type imagePullStatus struct {
Status string `json:"status"`
Progress string `json:"progress"`
}
var ErrNotAttached = errors.Sentinel("not attached to instance")
// A custom console writer that allows us to keep a function blocked until the
// given stream is properly closed. This does nothing special, only exists to
@@ -38,14 +36,14 @@ func (nw noopWriter) Write(b []byte) (int, error) {
return len(b), nil
}
// Attaches to the docker container itself and ensures that we can pipe data in and out
// of the process stream. This should not be used for reading console data as you *will*
// miss important output at the beginning because of the time delay with attaching to the
// output.
// Attach attaches to the docker container itself and ensures that we can pipe
// data in and out of the process stream. This should not be used for reading
// console data as you *will* miss important output at the beginning because of
// the time delay with attaching to the output.
//
// Calling this function will poll resources for the container in the background until the
// provided context is canceled by the caller. Failure to cancel said context will cause
// background memory leaks as the goroutine will not exit.
// Calling this function will poll resources for the container in the background
// until the provided context is canceled by the caller. Failure to cancel said
// context will cause background memory leaks as the goroutine will not exit.
func (e *Environment) Attach() error {
if e.IsAttached() {
return nil
@@ -108,27 +106,15 @@ func (e *Environment) Attach() error {
return nil
}
func (e *Environment) resources() container.Resources {
l := e.Configuration.Limits()
return container.Resources{
Memory: l.BoundedMemoryLimit(),
MemoryReservation: l.MemoryLimit * 1_000_000,
MemorySwap: l.ConvertedSwap(),
CPUQuota: l.ConvertedCpuLimit(),
CPUPeriod: 100_000,
CPUShares: 1024,
BlkioWeight: l.IoWeight,
OomKillDisable: &l.OOMDisabled,
CpusetCpus: l.Threads,
}
}
// Performs an in-place update of the Docker container's resource limits without actually
// making any changes to the operational state of the container. This allows memory, cpu,
// and IO limitations to be adjusted on the fly for individual instances.
// InSituUpdate performs an in-place update of the Docker container's resource
// limits without actually making any changes to the operational state of the
// container. This allows memory, cpu, and IO limitations to be adjusted on the
// fly for individual instances.
func (e *Environment) InSituUpdate() error {
if _, err := e.client.ContainerInspect(context.Background(), e.Id); err != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
if _, err := e.client.ContainerInspect(ctx, e.Id); err != nil {
// If the container doesn't exist for some reason there really isn't anything
// we can do to fix that in this process (it doesn't make sense at least). In those
// cases just return without doing anything since we still want to save the configuration
@@ -138,25 +124,24 @@ func (e *Environment) InSituUpdate() error {
if client.IsErrNotFound(err) {
return nil
}
return err
return errors.Wrap(err, "environment/docker: could not inspect container")
}
u := container.UpdateConfig{
Resources: e.resources(),
// CPU pinning cannot be removed once it is applied to a container. The same is true
// for removing memory limits, a container must be re-created.
//
// @see https://github.com/moby/moby/issues/41946
if _, err := e.client.ContainerUpdate(ctx, e.Id, container.UpdateConfig{
Resources: e.Configuration.Limits().AsContainerResources(),
}); err != nil {
return errors.Wrap(err, "environment/docker: could not update container")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
if _, err := e.client.ContainerUpdate(ctx, e.Id, u); err != nil {
return err
}
return nil
}
// Creates a new container for the server using all of the data that is currently
// available for it. If the container already exists it will be returnee.
// Create creates a new container for the server using all of the data that is
// currently available for it. If the container already exists it will be
// returned.
func (e *Environment) Create() error {
// If the container already exists don't hit the user with an error, just return
// the current information about it which is what we would do when creating the
@@ -164,12 +149,12 @@ func (e *Environment) Create() error {
if _, err := e.client.ContainerInspect(context.Background(), e.Id); err == nil {
return nil
} else if !client.IsErrNotFound(err) {
return err
return errors.Wrap(err, "environment/docker: failed to inspect container")
}
// Try to pull the requested image before creating the container.
if err := e.ensureImageExists(e.meta.Image); err != nil {
return err
return errors.WithStackIf(err)
}
a := e.Configuration.Allocations()
@@ -193,7 +178,7 @@ func (e *Environment) Create() error {
OpenStdin: true,
Tty: true,
ExposedPorts: a.Exposed(),
Image: e.meta.Image,
Image: strings.TrimPrefix(e.meta.Image, "~"),
Env: e.Configuration.EnvironmentVariables(),
Labels: map[string]string{
"Service": "Pterodactyl",
@@ -218,7 +203,7 @@ func (e *Environment) Create() error {
// Define resource limits for the container based on the data passed through
// from the Panel.
Resources: e.resources(),
Resources: e.Configuration.Limits().AsContainerResources(),
DNS: config.Get().Docker.Network.Dns,
@@ -245,29 +230,14 @@ func (e *Environment) Create() error {
}
if _, err := e.client.ContainerCreate(context.Background(), conf, hostConf, nil, nil, e.Id); err != nil {
return err
return errors.Wrap(err, "environment/docker: failed to create container")
}
return nil
}
func (e *Environment) convertMounts() []mount.Mount {
var out []mount.Mount
for _, m := range e.Configuration.Mounts() {
out = append(out, mount.Mount{
Type: mount.TypeBind,
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
return out
}
// Remove the Docker container from the machine. If the container is currently running
// it will be forcibly stopped by Docker.
// Destroy will remove the Docker container from the server. If the container
// is currently running it will be forcibly stopped by Docker.
func (e *Environment) Destroy() error {
// We set it to stopping than offline to prevent crash detection from being triggered.
e.SetState(environment.ProcessStoppingState)
@@ -291,9 +261,55 @@ func (e *Environment) Destroy() error {
return err
}
// Attaches to the log for the container. This avoids us missing crucial output that
// happens in the split seconds before the code moves from 'Starting' to 'Attaching'
// on the process.
// SendCommand sends the specified command to the stdin of the running container
// instance. There is no confirmation that this data is sent successfully, only
// that it gets pushed into the stdin.
func (e *Environment) SendCommand(c string) error {
if !e.IsAttached() {
return errors.Wrap(ErrNotAttached, "environment/docker: cannot send command to container")
}
e.mu.RLock()
defer e.mu.RUnlock()
// If the command being processed is the same as the process stop command then we
// want to mark the server as entering the stopping state otherwise the process will
// stop and Wings will think it has crashed and attempt to restart it.
if e.meta.Stop.Type == "command" && c == e.meta.Stop.Value {
e.SetState(environment.ProcessStoppingState)
}
_, err := e.stream.Conn.Write([]byte(c + "\n"))
return errors.Wrap(err, "environment/docker: could not write to container stream")
}
// Readlog reads the log file for the server. This does not care if the server
// is running or not, it will simply try to read the last X bytes of the file
// and return them.
func (e *Environment) Readlog(lines int) ([]string, error) {
r, err := e.client.ContainerLogs(context.Background(), e.Id, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: strconv.Itoa(lines),
})
if err != nil {
return nil, errors.WithStack(err)
}
defer r.Close()
var out []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
out = append(out, scanner.Text())
}
return out, nil
}
// Attaches to the log for the container. This avoids us missing crucial output
// that happens in the split seconds before the code moves from 'Starting' to
// 'Attaching' on the process.
func (e *Environment) followOutput() error {
if exists, err := e.Exists(); !exists {
if err != nil {
@@ -346,14 +362,19 @@ func (e *Environment) scanOutput(reader io.ReadCloser) {
go e.followOutput()
}
// Pulls the image from Docker. If there is an error while pulling the image from the source
// but the image already exists locally, we will report that error to the logger but continue
// with the process.
type imagePullStatus struct {
Status string `json:"status"`
Progress string `json:"progress"`
}
// Pulls the image from Docker. If there is an error while pulling the image
// from the source but the image already exists locally, we will report that
// error to the logger but continue with the process.
//
// The reasoning behind this is that Quay has had some serious outages as of late, and we don't
// need to block all of the servers from booting just because of that. I'd imagine in a lot of
// cases an outage shouldn't affect users too badly. It'll at least keep existing servers working
// correctly if anything.
// The reasoning behind this is that Quay has had some serious outages as of
// late, and we don't need to block all of the servers from booting just because
// of that. I'd imagine in a lot of cases an outage shouldn't affect users too
// badly. It'll at least keep existing servers working correctly if anything.
func (e *Environment) ensureImageExists(image string) error {
e.Events().Publish(environment.DockerImagePullStarted, "")
defer e.Events().Publish(environment.DockerImagePullCompleted, "")
@@ -399,7 +420,7 @@ func (e *Environment) ensureImageExists(image string) error {
if ierr != nil {
// Well damn, something has gone really wrong here, just go ahead and abort there
// isn't much anything we can do to try and self-recover from this.
return ierr
return errors.Wrap(ierr, "environment/docker: failed to list images")
}
for _, img := range images {
@@ -420,7 +441,7 @@ func (e *Environment) ensureImageExists(image string) error {
}
}
return err
return errors.Wrapf(err, "environment/docker: failed to pull \"%s\" image for server", image)
}
defer out.Close()
@@ -447,3 +468,36 @@ func (e *Environment) ensureImageExists(image string) error {
return nil
}
func (e *Environment) convertMounts() []mount.Mount {
var out []mount.Mount
for _, m := range e.Configuration.Mounts() {
out = append(out, mount.Mount{
Type: mount.TypeBind,
Source: m.Source,
Target: m.Target,
ReadOnly: m.ReadOnly,
})
}
return out
}
func (e *Environment) resources() container.Resources {
l := e.Configuration.Limits()
pids := l.ProcessLimit()
return container.Resources{
Memory: l.BoundedMemoryLimit(),
MemoryReservation: l.MemoryLimit * 1_000_000,
MemorySwap: l.ConvertedSwap(),
CPUQuota: l.ConvertedCpuLimit(),
CPUPeriod: 100_000,
CPUShares: 1024,
BlkioWeight: l.IoWeight,
OomKillDisable: &l.OOMDisabled,
CpusetCpus: l.Threads,
PidsLimit: &pids,
}
}

View File

@@ -3,6 +3,7 @@ package docker
import (
"context"
"fmt"
"github.com/pterodactyl/wings/metrics"
"io"
"sync"
@@ -212,5 +213,15 @@ func (e *Environment) SetState(state string) {
// If the state changed make sure we update the internal tracking to note that.
e.st.Store(state)
e.Events().Publish(environment.StateChangeEvent, state)
if state == environment.ProcessRunningState || state == environment.ProcessOfflineState {
val := 0
if state == environment.ProcessRunningState {
val = 1
} else {
metrics.ResetServer(e.Id)
}
metrics.ServerStatus.WithLabelValues(e.Id).Set(float64(val))
}
}
}

View File

@@ -2,6 +2,11 @@ package docker
import (
"context"
"os"
"strings"
"syscall"
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/docker/docker/api/types"
@@ -9,11 +14,6 @@ import (
"github.com/docker/docker/client"
"github.com/pterodactyl/wings/environment"
"github.com/pterodactyl/wings/remote"
"os"
"strings"
"syscall"
"time"
)
// Run before the container starts and get the process configuration from the Panel.
@@ -27,7 +27,7 @@ func (e *Environment) OnBeforeStart() error {
// Always destroy and re-create the server container to ensure that synced data from the Panel is used.
if err := e.client.ContainerRemove(context.Background(), e.Id, types.ContainerRemoveOptions{RemoveVolumes: true}); err != nil {
if !client.IsErrNotFound(err) {
return errors.WithMessage(err, "failed to remove server docker container during pre-boot")
return errors.WrapIf(err, "environment/docker: failed to remove container during pre-boot")
}
}
@@ -71,7 +71,7 @@ func (e *Environment) Start() error {
//
// @see https://github.com/pterodactyl/panel/issues/2000
if !client.IsErrNotFound(err) {
return err
return errors.WrapIf(err, "environment/docker: failed to inspect container")
}
} else {
// If the server is running update our internal state and continue on with the attach.
@@ -86,7 +86,7 @@ func (e *Environment) Start() error {
// to truncate them.
if _, err := os.Stat(c.LogPath); err == nil {
if err := os.Truncate(c.LogPath, 0); err != nil {
return err
return errors.Wrap(err, "environment/docker: failed to truncate instance logs")
}
}
}
@@ -101,14 +101,14 @@ func (e *Environment) Start() error {
// exists on the system, and rebuild the container if that is required for server booting to
// occur.
if err := e.OnBeforeStart(); err != nil {
return err
return errors.WithStackIf(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
if err := e.client.ContainerStart(ctx, e.Id, types.ContainerStartOptions{}); err != nil {
return err
return errors.WrapIf(err, "environment/docker: failed to start container")
}
// No errors, good to continue through.
@@ -174,7 +174,7 @@ func (e *Environment) Stop() error {
e.SetState(environment.ProcessOfflineState)
return nil
}
return err
return errors.Wrap(err, "environment/docker: cannot stop container")
}
return nil
@@ -208,7 +208,9 @@ func (e *Environment) WaitForStop(seconds uint, terminate bool) error {
return ctxErr
}
case err := <-errChan:
if err != nil {
// If the error stems from the container not existing there is no point in wasting
// CPU time to then try and terminate it.
if err != nil && !client.IsErrNotFound(err) {
if terminate {
l := log.WithField("container_id", e.Id)
if errors.Is(err, context.DeadlineExceeded) {
@@ -219,8 +221,7 @@ func (e *Environment) WaitForStop(seconds uint, terminate bool) error {
return e.Terminate(os.Kill)
}
return err
return errors.WrapIf(err, "environment/docker: error waiting on container to enter \"not-running\" state")
}
case <-ok:
}
@@ -232,7 +233,12 @@ func (e *Environment) WaitForStop(seconds uint, terminate bool) error {
func (e *Environment) Terminate(signal os.Signal) error {
c, err := e.client.ContainerInspect(context.Background(), e.Id)
if err != nil {
return err
// Treat missing containers as an okay error state, means it is obviously
// already terminated at this point.
if client.IsErrNotFound(err) {
return nil
}
return errors.WithStack(err)
}
if !c.State.Running {
@@ -249,13 +255,10 @@ func (e *Environment) Terminate(signal os.Signal) error {
// We set it to stopping than offline to prevent crash detection from being triggered.
e.SetState(environment.ProcessStoppingState)
sig := strings.TrimSuffix(strings.TrimPrefix(signal.String(), "signal "), "ed")
if err := e.client.ContainerKill(context.Background(), e.Id, sig); err != nil && !client.IsErrNotFound(err) {
return err
return errors.WithStack(err)
}
e.SetState(environment.ProcessOfflineState)
return nil

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"github.com/docker/docker/api/types"
"github.com/pterodactyl/wings/environment"
"github.com/pterodactyl/wings/metrics"
"io"
"math"
)
@@ -60,6 +61,11 @@ func (e *Environment) pollResources(ctx context.Context) error {
st.Network.TxBytes += nw.TxBytes
}
metrics.ServerCPU.WithLabelValues(e.Id).Set(st.CpuAbsolute)
metrics.ServerMemory.WithLabelValues(e.Id).Set(float64(st.Memory))
metrics.ServerNetworkRx.WithLabelValues(e.Id).Set(float64(st.Network.RxBytes))
metrics.ServerNetworkTx.WithLabelValues(e.Id).Set(float64(st.Network.TxBytes))
if b, err := json.Marshal(st); err != nil {
e.log().WithField("error", err).Warn("error while marshaling stats object for environment")
} else {

View File

@@ -1,100 +0,0 @@
package docker
import (
"bufio"
"bytes"
"context"
"emperror.dev/errors"
"encoding/json"
"github.com/docker/docker/api/types"
"github.com/pterodactyl/wings/environment"
"strconv"
)
type dockerLogLine struct {
Log string `json:"log"`
}
var ErrNotAttached = errors.New("not attached to instance")
func (e *Environment) setStream(s *types.HijackedResponse) {
e.mu.Lock()
defer e.mu.Unlock()
e.stream = s
}
// Sends the specified command to the stdin of the running container instance. There is no
// confirmation that this data is sent successfully, only that it gets pushed into the stdin.
func (e *Environment) SendCommand(c string) error {
if !e.IsAttached() {
return ErrNotAttached
}
e.mu.RLock()
defer e.mu.RUnlock()
// If the command being processed is the same as the process stop command then we want to mark
// the server as entering the stopping state otherwise the process will stop and Wings will think
// it has crashed and attempt to restart it.
if e.meta.Stop.Type == "command" && c == e.meta.Stop.Value {
e.SetState(environment.ProcessStoppingState)
}
_, err := e.stream.Conn.Write([]byte(c + "\n"))
return err
}
// Reads the log file for the server. This does not care if the server is running or not, it will
// simply try to read the last X bytes of the file and return them.
func (e *Environment) Readlog(lines int) ([]string, error) {
r, err := e.client.ContainerLogs(context.Background(), e.Id, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: strconv.Itoa(lines),
})
if err != nil {
return nil, err
}
defer r.Close()
var out []string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
out = append(out, scanner.Text())
}
return out, nil
}
// Docker stores the logs for server output in a JSON format. This function will iterate over the JSON
// that was read from the log file and parse it into a more human readable format.
func (e *Environment) parseLogToStrings(b []byte) ([]string, error) {
hasError := false
var out []string
scanner := bufio.NewScanner(bytes.NewReader(b))
for scanner.Scan() {
var l dockerLogLine
// Unmarshal the contents and allow up to a single error before bailing out of the process. We
// do this because if you're arbitrarily reading a length of the file you'll likely end up
// with the first line in the output being improperly formatted JSON. In those cases we want to
// just skip over it. However if we see another error we're going to bail out because that is an
// abnormal situation.
if err := json.Unmarshal([]byte(scanner.Text()), &l); err != nil {
if hasError {
return nil, err
}
hasError = true
continue
}
out = append(out, l.Log)
}
return out, nil
}

View File

@@ -6,6 +6,8 @@ import (
"strconv"
"github.com/apex/log"
"github.com/docker/docker/api/types/container"
"github.com/pterodactyl/wings/config"
)
type Mount struct {
@@ -28,8 +30,8 @@ type Mount struct {
ReadOnly bool `json:"read_only"`
}
// The build settings for a given server that impact docker container creation and
// resource limits for a server instance.
// Limits is the build settings for a given server that impact docker container
// creation and resource limits for a server instance.
type Limits struct {
// The total amount of memory in megabytes that this server is allowed to
// use on the host system.
@@ -56,51 +58,76 @@ type Limits struct {
OOMDisabled bool `json:"oom_disabled"`
}
// Converts the CPU limit for a server build into a number that can be better understood
// by the Docker environment. If there is no limit set, return -1 which will indicate to
// Docker that it has unlimited CPU quota.
func (r *Limits) ConvertedCpuLimit() int64 {
if r.CpuLimit == 0 {
// ConvertedCpuLimit converts the CPU limit for a server build into a number
// that can be better understood by the Docker environment. If there is no limit
// set, return -1 which will indicate to Docker that it has unlimited CPU quota.
func (l Limits) ConvertedCpuLimit() int64 {
if l.CpuLimit == 0 {
return -1
}
return r.CpuLimit * 1000
return l.CpuLimit * 1000
}
// Set the hard limit for memory usage to be 5% more than the amount of memory assigned to
// the server. If the memory limit for the server is < 4G, use 10%, if less than 2G use
// 15%. This avoids unexpected crashes from processes like Java which run over the limit.
func (r *Limits) MemoryOverheadMultiplier() float64 {
if r.MemoryLimit <= 2048 {
// MemoryOverheadMultiplier sets the hard limit for memory usage to be 5% more
// than the amount of memory assigned to the server. If the memory limit for the
// server is < 4G, use 10%, if less than 2G use 15%. This avoids unexpected
// crashes from processes like Java which run over the limit.
func (l Limits) MemoryOverheadMultiplier() float64 {
if l.MemoryLimit <= 2048 {
return 1.15
} else if r.MemoryLimit <= 4096 {
} else if l.MemoryLimit <= 4096 {
return 1.10
}
return 1.05
}
func (r *Limits) BoundedMemoryLimit() int64 {
return int64(math.Round(float64(r.MemoryLimit) * r.MemoryOverheadMultiplier() * 1_000_000))
func (l Limits) BoundedMemoryLimit() int64 {
return int64(math.Round(float64(l.MemoryLimit) * l.MemoryOverheadMultiplier() * 1_000_000))
}
// Returns the amount of swap available as a total in bytes. This is returned as the amount
// of memory available to the server initially, PLUS the amount of additional swap to include
// which is the format used by Docker.
func (r *Limits) ConvertedSwap() int64 {
if r.Swap < 0 {
// ConvertedSwap returns the amount of swap available as a total in bytes. This
// is returned as the amount of memory available to the server initially, PLUS
// the amount of additional swap to include which is the format used by Docker.
func (l Limits) ConvertedSwap() int64 {
if l.Swap < 0 {
return -1
}
return (r.Swap * 1_000_000) + r.BoundedMemoryLimit()
return (l.Swap * 1_000_000) + l.BoundedMemoryLimit()
}
// ProcessLimit returns the process limit for a container. This is currently
// defined at a system level and not on a per-server basis.
func (l Limits) ProcessLimit() int64 {
return config.Get().Docker.ContainerPidLimit
}
func (l Limits) AsContainerResources() container.Resources {
pids := l.ProcessLimit()
return container.Resources{
Memory: l.BoundedMemoryLimit(),
MemoryReservation: l.MemoryLimit * 1_000_000,
MemorySwap: l.ConvertedSwap(),
CPUQuota: l.ConvertedCpuLimit(),
CPUPeriod: 100_000,
CPUShares: 1024,
BlkioWeight: l.IoWeight,
OomKillDisable: &l.OOMDisabled,
CpusetCpus: l.Threads,
PidsLimit: &pids,
}
}
type Variables map[string]interface{}
// Ugly hacky function to handle environment variables that get passed through as not-a-string
// from the Panel. Ideally we'd just say only pass strings, but that is a fragile idea and if a
// string wasn't passed through you'd cause a crash or the server to become unavailable. For now
// try to handle the most likely values from the JSON and hope for the best.
// Get is an ugly hacky function to handle environment variables that get passed
// through as not-a-string from the Panel. Ideally we'd just say only pass
// strings, but that is a fragile idea and if a string wasn't passed through
// you'd cause a crash or the server to become unavailable. For now try to
// handle the most likely values from the JSON and hope for the best.
func (v Variables) Get(key string) string {
val, ok := v[key]
if !ok {

1
go.mod
View File

@@ -14,6 +14,7 @@ require (
github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef
github.com/beevik/etree v1.1.0
github.com/buger/jsonparser v1.1.0
github.com/cenkalti/backoff/v4 v4.1.0
github.com/cobaugh/osrelease v0.0.0-20181218015638-a93a0a55a249
github.com/containerd/containerd v1.4.3 // indirect
github.com/containerd/fifo v0.0.0-20201026212402-0724c46b320c // indirect

3
go.sum
View File

@@ -73,7 +73,10 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
github.com/buger/jsonparser v1.1.0 h1:EPAGdKZgZCON4ZcMD+h4l/NN4ndr6ijSpj4INh8PbUY=
github.com/buger/jsonparser v1.1.0/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cenkalti/backoff/v4 v4.1.0 h1:c8LkOFQTzuO0WBM/ae5HdGQuZPfPxp7lqBRwQRm4fSc=
github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=

107
metrics/metrics.go Normal file
View File

@@ -0,0 +1,107 @@
package metrics
import (
"github.com/apex/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/pterodactyl/wings/config"
"net/http"
"time"
)
type Metrics struct {
handler http.Handler
}
const (
namespace = "pterodactyl"
subsystem = "wings"
)
var (
bootTimeSeconds = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "boot_time_seconds",
Help: "Boot time of this instance since epoch (1970)",
})
timeSeconds = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "time_seconds",
Help: "System time in seconds since epoch (1970)",
})
ServerStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "server_status",
}, []string{"server_id"})
ServerCPU = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "server_cpu",
}, []string{"server_id"})
ServerMemory = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "server_memory",
}, []string{"server_id"})
ServerNetworkRx = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "server_network_rx",
}, []string{"server_id"})
ServerNetworkTx = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "server_network_tx",
}, []string{"server_id"})
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "http_requests_total",
}, []string{"method", "route_path", "raw_path", "raw_query", "code"})
)
func Initialize(done chan bool) {
bootTimeSeconds.Set(float64(time.Now().UnixNano()) / 1e9)
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-done:
// Received a "signal" on the done channel.
log.Debug("metrics: done")
return
case t := <-ticker.C:
// Update the current time.
timeSeconds.Set(float64(t.UnixNano()) / 1e9)
}
}
}()
if err := http.ListenAndServe(config.Get().Metrics.Bind, promhttp.Handler()); err != nil && err != http.ErrServerClosed {
log.WithField("error", err).Error("failed to start metrics server")
}
}
// DeleteServer will remove any existing labels from being scraped by Prometheus.
// Any previously scraped data will still be persisted by Prometheus.
func DeleteServer(sID string) {
ServerStatus.DeleteLabelValues(sID)
ServerCPU.DeleteLabelValues(sID)
ServerMemory.DeleteLabelValues(sID)
ServerNetworkRx.DeleteLabelValues(sID)
ServerNetworkTx.DeleteLabelValues(sID)
}
// ResetServer will reset a server's metrics to their default values except the status.
func ResetServer(sID string) {
ServerCPU.WithLabelValues(sID).Set(0)
ServerMemory.WithLabelValues(sID).Set(0)
ServerNetworkRx.WithLabelValues(sID).Set(0)
ServerNetworkTx.WithLabelValues(sID).Set(0)
}

View File

@@ -30,6 +30,45 @@ const (
Xml = "xml"
)
type ReplaceValue struct {
value []byte
valueType jsonparser.ValueType
}
// Value returns the underlying value of the replacement. Be aware that this
// can include escaped UTF-8 sequences that will need to be handled by the caller
// in order to avoid accidentally injecting invalid sequences into the running
// process.
//
// For example the expected value may be "§Foo" but you'll be working directly
// with "\u00a7FOo" for this value. This will cause user pain if not solved since
// that is clearly not the value they were expecting to be using.
func (cv *ReplaceValue) Value() []byte {
return cv.value
}
// Type returns the underlying data type for the Value field.
func (cv *ReplaceValue) Type() jsonparser.ValueType {
return cv.valueType
}
// String returns the value as a string representation. This will automatically
// handle casting the UTF-8 sequence into the expected value, switching something
// like "\u00a7Foo" into "§Foo".
func (cv *ReplaceValue) String() string {
if cv.Type() != jsonparser.String {
if cv.Type() == jsonparser.Null {
return "<nil>"
}
return "<invalid>"
}
str, err := jsonparser.ParseString(cv.value)
if err != nil {
panic(errors.Wrap(err, "parser: could not parse value"))
}
return str
}
type ConfigurationParser string
func (cp ConfigurationParser) String() string {
@@ -77,15 +116,16 @@ func (f *ConfigurationFile) UnmarshalJSON(data []byte) error {
return nil
}
// Defines a single find/replace instance for a given server configuration file.
// ConfigurationFileReplacement defines a single find/replace instance for a
// given server configuration file.
type ConfigurationFileReplacement struct {
Match string `json:"match"`
IfValue string `json:"if_value"`
ReplaceWith ReplaceValue `json:"replace_with"`
}
// Handles unmarshaling the JSON representation into a struct that provides more useful
// data to this functionality.
// UnmarshalJSON handles unmarshaling the JSON representation into a struct that
// provides more useful data to this functionality.
func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error {
m, err := jsonparser.GetString(data, "match")
if err != nil {
@@ -410,48 +450,66 @@ func (f *ConfigurationFile) parseTextFile(path string) error {
return nil
}
// Parses a properties file and updates the values within it to match those that
// are passed. Writes the file once completed.
// parsePropertiesFile parses a properties file and updates the values within it
// to match those that are passed. Once completed the new file is written to the
// disk. This will cause comments not present at the head of the file to be
// removed unfortunately.
//
// Any UTF-8 value will be written back to the disk as their escaped value rather
// than the raw value There is no winning with this logic. This fixes a bug where
// users with hand rolled UTF-8 escape sequences would have all sorts of pain in
// their configurations because we were writing the UTF-8 literal characters which
// their games could not actually handle.
//
// However, by adding this fix to only store the escaped UTF-8 sequence we
// unwittingly introduced a "regression" that causes _other_ games to have issues
// because they can only handle the unescaped representations. I cannot think of
// a simple approach to this problem that doesn't just lead to more complicated
// cases and problems.
//
// So, if your game cannot handle parsing UTF-8 sequences that are escaped into
// the string, well, sucks. There are fewer of those games than there are games
// that have issues parsing the raw UTF-8 sequence into a string? Also how does
// one really know what the user intended at this point? We'd need to know if
// the value was escaped or not to begin with before setting it, which I suppose
// can work but jesus that is going to be some annoyingly complicated logic?
//
// @see https://github.com/pterodactyl/panel/issues/2308 (original)
// @see https://github.com/pterodactyl/panel/issues/3009 ("bug" introduced as result)
func (f *ConfigurationFile) parsePropertiesFile(path string) error {
// Open the file.
f2, err := os.Open(path)
if err != nil {
return err
}
var s strings.Builder
// Get any header comments from the file.
scanner := bufio.NewScanner(f2)
// Open the file and attempt to load any comments that currenty exist at the start
// of the file. This is kind of a hack, but should work for a majority of users for
// the time being.
if fd, err := os.Open(path); err != nil {
return errors.Wrap(err, "parser: could not open file for reading")
} else {
scanner := bufio.NewScanner(fd)
// Scan until we hit a line that is not a comment that actually has content
// on it. Keep appending the comments until that time.
for scanner.Scan() {
text := scanner.Text()
if len(text) > 0 && text[0] != '#' {
break
}
s.WriteString(text)
s.WriteString("\n")
s.WriteString(text + "\n")
}
// Close the file.
_ = f2.Close()
// Handle any scanner errors.
_ = fd.Close()
if err := scanner.Err(); err != nil {
return err
return errors.WithStackIf(err)
}
}
// Decode the properties file.
p, err := properties.LoadFile(path, properties.UTF8)
if err != nil {
return err
return errors.Wrap(err, "parser: could not load properties file for configuration update")
}
// Replace any values that need to be replaced.
for _, replace := range f.Replace {
data, err := f.LookupConfigurationValue(replace)
if err != nil {
return err
return errors.Wrap(err, "parser: failed to lookup configuration value")
}
v, ok := p.Get(replace.Match)
@@ -463,7 +521,7 @@ func (f *ConfigurationFile) parsePropertiesFile(path string) error {
}
if _, _, err := p.Set(replace.Match, data); err != nil {
return err
return errors.Wrap(err, "parser: failed to set replacement value")
}
}
@@ -473,11 +531,11 @@ func (f *ConfigurationFile) parsePropertiesFile(path string) error {
if !ok {
continue
}
s.WriteString(key)
s.WriteByte('=')
s.WriteString(strings.Trim(strconv.QuoteToASCII(value), `"`))
s.WriteString("\n")
// This escape is intentional!
//
// See the docblock for this function for more details, do not change this
// or you'll cause a flood of new issue reports no one wants to deal with.
s.WriteString(key + "=" + strings.Trim(strconv.QuoteToASCII(value), "\"") + "\n")
}
// Open the file for writing.
@@ -489,7 +547,7 @@ func (f *ConfigurationFile) parsePropertiesFile(path string) error {
// Write the data to the file.
if _, err := w.Write([]byte(s.String())); err != nil {
return err
return errors.Wrap(err, "parser: failed to write properties file to disk")
}
return nil

View File

@@ -1,24 +0,0 @@
package parser
import (
"github.com/buger/jsonparser"
)
type ReplaceValue struct {
value []byte
valueType jsonparser.ValueType `json:"-"`
}
func (cv *ReplaceValue) Value() []byte {
return cv.value
}
func (cv *ReplaceValue) String() string {
str, _ := jsonparser.ParseString(cv.value)
return str
}
func (cv *ReplaceValue) Type() jsonparser.ValueType {
return cv.valueType
}

View File

@@ -3,6 +3,8 @@ package remote
import (
"fmt"
"net/http"
"emperror.dev/errors"
)
type RequestErrors struct {
@@ -16,13 +18,31 @@ type RequestError struct {
Detail string `json:"detail"`
}
// IsRequestError checks if the given error is of the RequestError type.
func IsRequestError(err error) bool {
_, ok := err.(*RequestError)
return ok
var rerr *RequestError
if err == nil {
return false
}
return errors.As(err, &rerr)
}
// Returns the error response in a string form that can be more easily consumed.
// AsRequestError transforms the error into a RequestError if it is currently
// one, checking the wrap status from the other error handlers. If the error
// is not a RequestError nil is returned.
func AsRequestError(err error) *RequestError {
if err == nil {
return nil
}
var rerr *RequestError
if errors.As(err, &rerr) {
return rerr
}
return nil
}
// Error returns the error response in a string form that can be more easily
// consumed.
func (re *RequestError) Error() string {
c := 0
if re.response != nil {
@@ -32,6 +52,11 @@ func (re *RequestError) Error() string {
return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%d)", re.Code, re.Detail, c)
}
// StatusCode returns the status code of the response.
func (re *RequestError) StatusCode() int {
return re.response.StatusCode
}
type SftpInvalidCredentialsError struct {
}

View File

@@ -8,11 +8,13 @@ import (
"io"
"io/ioutil"
"net/http"
"strconv"
"strings"
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/cenkalti/backoff/v4"
"github.com/pterodactyl/wings/system"
)
@@ -35,7 +37,7 @@ type client struct {
baseUrl string
tokenId string
token string
attempts int
maxAttempts int
}
// New returns a new HTTP request client that is used for making authenticated
@@ -46,7 +48,7 @@ func New(base string, opts ...ClientOption) Client {
httpClient: &http.Client{
Timeout: time.Second * 15,
},
attempts: 1,
maxAttempts: 0,
}
for _, opt := range opts {
opt(&c)
@@ -71,11 +73,31 @@ func WithHttpClient(httpClient *http.Client) ClientOption {
}
}
// Get executes a HTTP GET request.
func (c *client) Get(ctx context.Context, path string, query q) (*Response, error) {
return c.request(ctx, http.MethodGet, path, nil, func(r *http.Request) {
q := r.URL.Query()
for k, v := range query {
q.Set(k, v)
}
r.URL.RawQuery = q.Encode()
})
}
// Post executes a HTTP POST request.
func (c *client) Post(ctx context.Context, path string, data interface{}) (*Response, error) {
b, err := json.Marshal(data)
if err != nil {
return nil, err
}
return c.request(ctx, http.MethodPost, path, bytes.NewBuffer(b))
}
// requestOnce creates a http request and executes it once. Prefer request()
// over this method when possible. It appends the path to the endpoint of the
// client and adds the authentication token to the request.
func (c *client) requestOnce(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
req, err := http.NewRequest(method, c.baseUrl+path, body)
req, err := http.NewRequestWithContext(ctx, method, c.baseUrl+path, body)
if err != nil {
return nil, err
}
@@ -92,45 +114,86 @@ func (c *client) requestOnce(ctx context.Context, method, path string, body io.R
debugLogRequest(req)
res, err := c.httpClient.Do(req.WithContext(ctx))
res, err := c.httpClient.Do(req)
return &Response{res}, err
}
// request executes a http request and attempts when errors occur.
// It appends the path to the endpoint of the client and adds the authentication token to the request.
func (c *client) request(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (res *Response, err error) {
for i := 0; i < c.attempts; i++ {
res, err = c.requestOnce(ctx, method, path, body, opts...)
if err == nil &&
res.StatusCode < http.StatusInternalServerError &&
res.StatusCode != http.StatusTooManyRequests {
break
}
}
// request executes a HTTP request against the Panel API. If there is an error
// encountered with the request it will be retried using an exponential backoff.
// If the error returned from the Panel is due to API throttling or because there
// are invalid authentication credentials provided the request will _not_ be
// retried by the backoff.
//
// This function automatically appends the path to the current client endpoint
// and adds the required authentication headers to the request that is being
// created. Errors returned will be of the RequestError type if there was some
// type of response from the API that can be parsed.
func (c *client) request(ctx context.Context, method, path string, body io.Reader, opts ...func(r *http.Request)) (*Response, error) {
var res *Response
err := backoff.Retry(func() error {
r, err := c.requestOnce(ctx, method, path, body, opts...)
if err != nil {
return nil, errors.WithStack(err)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return backoff.Permanent(err)
}
return
}
// get executes a http get request.
func (c *client) get(ctx context.Context, path string, query q) (*Response, error) {
return c.request(ctx, http.MethodGet, path, nil, func(r *http.Request) {
q := r.URL.Query()
for k, v := range query {
q.Set(k, v)
return errors.WrapIf(err, "http: request creation failed")
}
r.URL.RawQuery = q.Encode()
})
}
// post executes a http post request.
func (c *client) post(ctx context.Context, path string, data interface{}) (*Response, error) {
b, err := json.Marshal(data)
res = r
if r.HasError() {
// Close the request body after returning the error to free up resources.
defer r.Body.Close()
// Don't keep spamming the endpoint if we've already made too many requests or
// if we're not even authenticated correctly. Retrying generally won't fix either
// of these issues.
if r.StatusCode == http.StatusForbidden ||
r.StatusCode == http.StatusTooManyRequests ||
r.StatusCode == http.StatusUnauthorized {
return backoff.Permanent(r.Error())
}
return r.Error()
}
return nil
}, c.backoff(ctx))
if err != nil {
if v, ok := err.(*backoff.PermanentError); ok {
return nil, v.Unwrap()
}
return nil, err
}
return c.request(ctx, http.MethodPost, path, bytes.NewBuffer(b))
return res, nil
}
// backoff returns an exponential backoff function for use with remote API
// requests. This will allow an API call to be executed approximately 10 times
// before it is finally reported back as an error.
//
// This allows for issues with DNS resolution, or rare race conditions due to
// slower SQL queries on the Panel to potentially self-resolve without just
// immediately failing the first request. The example below shows the amount of
// time that has ellapsed between each call to the handler when an error is
// returned. You can tweak these values as needed to get the effect you desire.
//
// If maxAttempts is a value greater than 0 the backoff will be capped at a total
// number of executions, or the MaxElapsedTime, whichever comes first.
//
// call(): 0s
// call(): 552.330144ms
// call(): 1.63271196s
// call(): 2.94284202s
// call(): 4.525234711s
// call(): 6.865723375s
// call(): 11.37194223s
// call(): 14.593421816s
// call(): 20.202045293s
// call(): 27.36567952s <-- Stops here as MaxElapsedTime is 30 seconds
func (c *client) backoff(ctx context.Context) backoff.BackOffContext {
b := backoff.NewExponentialBackOff()
b.MaxInterval = time.Second * 12
b.MaxElapsedTime = time.Second * 30
if c.maxAttempts > 0 {
return backoff.WithContext(backoff.WithMaxRetries(b, uint64(c.maxAttempts)), ctx)
}
return backoff.WithContext(b, ctx)
}
// Response is a custom response type that allows for commonly used error
@@ -157,15 +220,12 @@ func (r *Response) HasError() bool {
func (r *Response) Read() ([]byte, error) {
var b []byte
if r.Response == nil {
return nil, errors.New("http: attempting to read missing response")
return nil, errors.New("remote: attempting to read missing response")
}
if r.Response.Body != nil {
b, _ = ioutil.ReadAll(r.Response.Body)
}
r.Response.Body = ioutil.NopCloser(bytes.NewBuffer(b))
return b, nil
}
@@ -177,15 +237,16 @@ func (r *Response) BindJSON(v interface{}) error {
if err != nil {
return err
}
if err := json.Unmarshal(b, &v); err != nil {
return errors.Wrap(err, "http: could not unmarshal response")
return errors.Wrap(err, "remote: could not unmarshal response")
}
return nil
}
// Returns the first error message from the API call as a string. The error
// message will be formatted similar to the below example:
// message will be formatted similar to the below example. If there is no error
// that can be parsed out of the API you'll still get a RequestError returned
// but the RequestError.Code will be "_MissingResponseCode".
//
// HttpNotFoundException: The requested resource does not exist. (HTTP/404)
func (r *Response) Error() error {
@@ -196,14 +257,18 @@ func (r *Response) Error() error {
var errs RequestErrors
_ = r.BindJSON(&errs)
e := &RequestError{}
e := &RequestError{
Code: "_MissingResponseCode",
Status: strconv.Itoa(r.StatusCode),
Detail: "No error response returned from API endpoint.",
}
if len(errs.Errors) > 0 {
e = &errs.Errors[0]
}
e.response = r.Response
return e
return errors.WithStackDepth(e, 1)
}
// Logs the request into the debug log with all of the important request bits.

View File

@@ -14,8 +14,7 @@ func createTestClient(h http.HandlerFunc) (*client, *httptest.Server) {
c := &client{
httpClient: s.Client(),
baseUrl: s.URL,
attempts: 1,
maxAttempts: 1,
tokenId: "testid",
token: "testtoken",
}
@@ -47,7 +46,7 @@ func TestRequestRetry(t *testing.T) {
}
i++
})
c.attempts = 2
c.maxAttempts = 2
r, err := c.request(context.Background(), "", "", nil)
assert.NoError(t, err)
assert.NotNil(t, r)
@@ -60,12 +59,15 @@ func TestRequestRetry(t *testing.T) {
rw.WriteHeader(http.StatusInternalServerError)
i++
})
c.attempts = 2
c.maxAttempts = 2
r, err = c.request(context.Background(), "get", "", nil)
assert.NoError(t, err)
assert.NotNil(t, r)
assert.Equal(t, http.StatusInternalServerError, r.StatusCode)
assert.Equal(t, 2, i)
assert.Error(t, err)
assert.Nil(t, r)
v := AsRequestError(err)
assert.NotNil(t, v)
assert.Equal(t, http.StatusInternalServerError, v.StatusCode())
assert.Equal(t, 3, i)
}
func TestGet(t *testing.T) {
@@ -74,7 +76,7 @@ func TestGet(t *testing.T) {
assert.Len(t, r.URL.Query(), 1)
assert.Equal(t, "world", r.URL.Query().Get("hello"))
})
r, err := c.get(context.Background(), "/test", q{"hello": "world"})
r, err := c.Get(context.Background(), "/test", q{"hello": "world"})
assert.NoError(t, err)
assert.NotNil(t, r)
}
@@ -87,7 +89,7 @@ func TestPost(t *testing.T) {
assert.Equal(t, http.MethodPost, r.Method)
})
r, err := c.post(context.Background(), "/test", test)
r, err := c.Post(context.Background(), "/test", test)
assert.NoError(t, err)
assert.NotNil(t, r)
}

View File

@@ -58,62 +58,54 @@ func (c *client) GetServers(ctx context.Context, limit int) ([]RawServerData, er
// things in a bad state within the Panel. This API call is executed once Wings
// has fully booted all of the servers.
func (c *client) ResetServersState(ctx context.Context) error {
res, err := c.post(ctx, "/servers/reset", nil)
res, err := c.Post(ctx, "/servers/reset", nil)
if err != nil {
return errors.WrapIf(err, "remote/servers: failed to reset server state on Panel")
return errors.WrapIf(err, "remote: failed to reset server state on Panel")
}
res.Body.Close()
_ = res.Body.Close()
return nil
}
func (c *client) GetServerConfiguration(ctx context.Context, uuid string) (ServerConfigurationResponse, error) {
var config ServerConfigurationResponse
res, err := c.get(ctx, fmt.Sprintf("/servers/%s", uuid), nil)
res, err := c.Get(ctx, fmt.Sprintf("/servers/%s", uuid), nil)
if err != nil {
return config, err
}
defer res.Body.Close()
if res.HasError() {
return config, res.Error()
}
err = res.BindJSON(&config)
return config, err
}
func (c *client) GetInstallationScript(ctx context.Context, uuid string) (InstallationScript, error) {
res, err := c.get(ctx, fmt.Sprintf("/servers/%s/install", uuid), nil)
res, err := c.Get(ctx, fmt.Sprintf("/servers/%s/install", uuid), nil)
if err != nil {
return InstallationScript{}, err
}
defer res.Body.Close()
if res.HasError() {
return InstallationScript{}, res.Error()
}
var config InstallationScript
err = res.BindJSON(&config)
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})
resp, err := c.Post(ctx, fmt.Sprintf("/servers/%s/install", uuid), d{"successful": successful})
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
_ = resp.Body.Close()
return nil
}
func (c *client) SetArchiveStatus(ctx context.Context, uuid string, successful bool) error {
resp, err := c.post(ctx, fmt.Sprintf("/servers/%s/archive", uuid), d{"successful": successful})
resp, err := c.Post(ctx, fmt.Sprintf("/servers/%s/archive", uuid), d{"successful": successful})
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
_ = resp.Body.Close()
return nil
}
func (c *client) SetTransferStatus(ctx context.Context, uuid string, successful bool) error {
@@ -121,12 +113,12 @@ func (c *client) SetTransferStatus(ctx context.Context, uuid string, successful
if successful {
state = "success"
}
resp, err := c.get(ctx, fmt.Sprintf("/servers/%s/transfer/%s", uuid, state), nil)
resp, err := c.Get(ctx, fmt.Sprintf("/servers/%s/transfer/%s", uuid, state), nil)
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
_ = resp.Body.Close()
return nil
}
// ValidateSftpCredentials makes a request to determine if the username and
@@ -136,66 +128,54 @@ func (c *client) SetTransferStatus(ctx context.Context, uuid string, successful
// all of 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)
res, err := c.Post(ctx, "/sftp/auth", request)
if err != nil {
if err := AsRequestError(err); err != nil && (err.StatusCode() >= 400 && err.StatusCode() < 500) {
log.WithFields(log.Fields{"subsystem": "sftp", "username": request.User, "ip": request.IP}).Warn(err.Error())
return auth, &SftpInvalidCredentialsError{}
}
return auth, err
}
defer res.Body.Close()
e := res.Error()
if e != nil {
if res.StatusCode >= 400 && res.StatusCode < 500 {
log.WithFields(log.Fields{
"subsystem": "sftp",
"username": request.User,
"ip": request.IP,
}).Warn(e.Error())
return auth, &SftpInvalidCredentialsError{}
}
return auth, errors.New(e.Error())
}
err = res.BindJSON(&auth)
if err := res.BindJSON(&auth); err != nil {
return auth, err
}
return auth, nil
}
func (c *client) GetBackupRemoteUploadURLs(ctx context.Context, backup string, size int64) (BackupRemoteUploadResponse, error) {
var data BackupRemoteUploadResponse
res, err := c.get(ctx, fmt.Sprintf("/backups/%s", backup), q{"size": strconv.FormatInt(size, 10)})
res, err := c.Get(ctx, fmt.Sprintf("/backups/%s", backup), q{"size": strconv.FormatInt(size, 10)})
if err != nil {
return data, err
}
defer res.Body.Close()
if res.HasError() {
return data, res.Error()
}
err = res.BindJSON(&data)
if err := res.BindJSON(&data); err != nil {
return data, err
}
return data, nil
}
func (c *client) SetBackupStatus(ctx context.Context, backup string, data BackupRequest) error {
resp, err := c.post(ctx, fmt.Sprintf("/backups/%s", backup), data)
resp, err := c.Post(ctx, fmt.Sprintf("/backups/%s", backup), data)
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
_ = resp.Body.Close()
return nil
}
// SendRestorationStatus triggers a request to the Panel to notify it that a
// restoration has been completed and the server should be marked as being
// activated again.
func (c *client) SendRestorationStatus(ctx context.Context, backup string, successful bool) error {
resp, err := c.post(ctx, fmt.Sprintf("/backups/%s/restore", backup), d{"successful": successful})
resp, err := c.Post(ctx, fmt.Sprintf("/backups/%s/restore", backup), d{"successful": successful})
if err != nil {
return err
}
defer resp.Body.Close()
return resp.Error()
_ = resp.Body.Close()
return nil
}
// getServersPaged returns a subset of servers from the Panel API using the
@@ -206,7 +186,7 @@ func (c *client) getServersPaged(ctx context.Context, page, limit int) ([]RawSer
Meta Pagination `json:"meta"`
}
res, err := c.get(ctx, "/servers", q{
res, err := c.Get(ctx, "/servers", q{
"page": strconv.Itoa(page),
"per_page": strconv.Itoa(limit),
})
@@ -214,10 +194,6 @@ func (c *client) getServersPaged(ctx context.Context, page, limit int) ([]RawSer
return nil, r.Meta, err
}
defer res.Body.Close()
if res.HasError() {
return nil, r.Meta, res.Error()
}
if err := res.BindJSON(&r); err != nil {
return nil, r.Meta, err
}

View File

@@ -134,7 +134,7 @@ func (e *RequestError) getAsFilesystemError() (int, string) {
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: file is a directory."
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."

View File

@@ -3,9 +3,11 @@ package middleware
import (
"context"
"crypto/subtle"
"github.com/pterodactyl/wings/metrics"
"io"
"net/http"
"os"
"strconv"
"strings"
"emperror.dev/errors"
@@ -352,3 +354,19 @@ func ExtractManager(c *gin.Context) *server.Manager {
}
panic("middleware/middleware: cannot extract server manager: not present in context")
}
func Metrics() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
rawQuery := c.Request.URL.RawQuery
c.Next()
// Skip over the server websocket endpoint.
if strings.HasSuffix(c.FullPath(), "/ws") {
return
}
metrics.HTTPRequestsTotal.WithLabelValues(c.Request.Method, c.FullPath(), path, rawQuery, strconv.Itoa(c.Writer.Status())).Inc()
}
}

View File

@@ -14,6 +14,7 @@ func Configure(m *server.Manager, client remote.Client) *gin.Engine {
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.Metrics())
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.

View File

@@ -16,19 +16,9 @@ import (
"github.com/pterodactyl/wings/server"
)
type serverProcData struct {
server.ResourceUsage
Suspended bool `json:"suspended"`
}
// Returns a single server from the collection of servers.
func getServer(c *gin.Context) {
s := ExtractServer(c)
c.JSON(http.StatusOK, serverProcData{
ResourceUsage: s.Proc(),
Suspended: s.IsSuspended(),
})
c.JSON(http.StatusOK, ExtractServer(c).ToAPIResponse())
}
// Returns the logs for a given server instance.
@@ -204,12 +194,6 @@ func deleteServer(c *gin.Context) {
s.Events().Destroy()
s.Websockets().CancelAll()
// Delete the server's archive if it exists. We intentionally don't return
// here, if the archive fails to delete, the server can still be removed.
if err := s.Archiver.DeleteIfExists(); err != nil {
s.Log().WithField("error", err).Warn("failed to delete server archive during deletion process")
}
// Remove any pending remote file downloads for the server.
for _, dl := range downloader.ByServer(s.Id()) {
dl.Cancel()

View File

@@ -63,7 +63,7 @@ func postServerBackup(c *gin.Context) {
// This endpoint will block until the backup is fully restored allowing for a
// spinner to be displayed in the Panel UI effectively.
//
// TODO: stop the server if it is running; internally mark it as suspended
// TODO: stop the server if it is running
func postServerRestoreBackup(c *gin.Context) {
s := middleware.ExtractServer(c)
client := middleware.ExtractApiClient(c)
@@ -84,9 +84,19 @@ func postServerRestoreBackup(c *gin.Context) {
return
}
s.SetRestoring(true)
hasError := true
defer func() {
if !hasError {
return
}
s.SetRestoring(false)
}()
logger.Info("processing server backup restore request")
if data.TruncateDirectory {
logger.Info(`recieved "truncate_directory" flag in request: deleting server files`)
logger.Info("received \"truncate_directory\" flag in request: deleting server files")
if err := s.Filesystem().TruncateRootDirectory(); err != nil {
middleware.CaptureAndAbort(c, err)
return
@@ -109,7 +119,9 @@ func postServerRestoreBackup(c *gin.Context) {
s.Events().Publish(server.DaemonMessageEvent, "Completed server restoration from local backup.")
s.Events().Publish(server.BackupRestoreCompletedEvent, "")
logger.Info("completed server restoration from local backup")
s.SetRestoring(false)
}(s, b, logger)
hasError = false
c.Status(http.StatusAccepted)
return
}
@@ -136,7 +148,7 @@ func postServerRestoreBackup(c *gin.Context) {
}
// Don't allow content types that we know are going to give us problems.
if res.Header.Get("Content-Type") == "" || !strings.Contains("application/x-gzip application/gzip", res.Header.Get("Content-Type")) {
res.Body.Close()
_ = res.Body.Close()
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "The provided backup link is not a supported content type. \"" + res.Header.Get("Content-Type") + "\" is not application/x-gzip.",
})
@@ -151,8 +163,10 @@ func postServerRestoreBackup(c *gin.Context) {
s.Events().Publish(server.DaemonMessageEvent, "Completed server restoration from S3 backup.")
s.Events().Publish(server.BackupRestoreCompletedEvent, "")
logger.Info("completed server restoration from S3 backup")
s.SetRestoring(false)
}(s, c.Param("backup"), logger)
hasError = false
c.Status(http.StatusAccepted)
}

View File

@@ -3,6 +3,7 @@ package router
import (
"bufio"
"context"
"io"
"mime/multipart"
"net/http"
"net/url"
@@ -43,8 +44,16 @@ func getServerFileContents(c *gin.Context) {
c.Header("Content-Type", "application/octet-stream")
}
defer c.Writer.Flush()
_, err = bufio.NewReader(f).WriteTo(c.Writer)
if err != nil {
// If you don't do a limited reader here you will trigger a panic on write when
// a different server process writes content to the file after you've already
// determined the file size. This could lead to some weird content output but
// it would technically be accurate based on the content at the time of the request.
//
// "http: wrote more than the declared Content-Length"
//
// @see https://github.com/pterodactyl/panel/issues/3131
r := io.LimitReader(f, st.Size())
if _, err = bufio.NewReader(r).WriteTo(c.Writer); 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?
@@ -374,8 +383,6 @@ func postServerCompressFiles(c *gin.Context) {
// of unpacking an archive that exists on the server into the provided RootPath
// for the server.
func postServerDecompressFiles(c *gin.Context) {
s := middleware.ExtractServer(c)
lg := middleware.ExtractLogger(c)
var data struct {
RootPath string `json:"root"`
File string `json:"file"`
@@ -384,7 +391,8 @@ func postServerDecompressFiles(c *gin.Context) {
return
}
lg = lg.WithFields(log.Fields{"root_path": data.RootPath, "file": data.File})
s := middleware.ExtractServer(c)
lg := middleware.ExtractLogger(c).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 {
@@ -403,7 +411,7 @@ func postServerDecompressFiles(c *gin.Context) {
// 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") {
lg.WithField("error", err).Warn("failed to decompress file: text file busy")
lg.WithField("error", errors.WithStackIf(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.",
})

View File

@@ -10,6 +10,7 @@ import (
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/installer"
"github.com/pterodactyl/wings/router/middleware"
"github.com/pterodactyl/wings/server"
"github.com/pterodactyl/wings/system"
)
@@ -28,7 +29,12 @@ func getSystemInformation(c *gin.Context) {
// Returns all of the servers that are registered and configured correctly on
// this wings instance.
func getAllServers(c *gin.Context) {
c.JSON(http.StatusOK, middleware.ExtractManager(c).All())
servers := middleware.ExtractManager(c).All()
out := make([]server.APIResponse, len(servers), len(servers))
for i, v := range servers {
out[i] = v.ToAPIResponse()
}
c.JSON(http.StatusOK, out)
}
// Creates a new server on the wings daemon and begins the installation process

View File

@@ -29,6 +29,7 @@ import (
"github.com/pterodactyl/wings/router/middleware"
"github.com/pterodactyl/wings/router/tokens"
"github.com/pterodactyl/wings/server"
"github.com/pterodactyl/wings/server/filesystem"
"github.com/pterodactyl/wings/system"
)
@@ -51,6 +52,10 @@ type serverTransferRequest struct {
Server json.RawMessage `json:"server"`
}
func getArchivePath(sID string) string {
return filepath.Join(config.Get().System.ArchiveDirectory, sID+".tar.gz")
}
// Returns the archive for a server so that it can be transferred to a new node.
func getServerArchive(c *gin.Context) {
auth := strings.SplitN(c.GetHeader("Authorization"), " ", 2)
@@ -77,36 +82,51 @@ func getServerArchive(c *gin.Context) {
return
}
st, err := s.Archiver.Stat()
archivePath := getArchivePath(s.Id())
// Stat the archive file.
st, err := os.Lstat(archivePath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
WithError(c, err)
_ = WithError(c, err)
return
}
c.AbortWithStatus(http.StatusNotFound)
return
}
checksum, err := s.Archiver.Checksum()
// Compute sha1 checksum.
h := sha256.New()
f, err := os.Open(archivePath)
if err != nil {
NewServerError(err, s).SetMessage("failed to calculate checksum").Abort(c)
return
}
if _, err := io.Copy(h, bufio.NewReader(f)); err != nil {
_ = f.Close()
_ = WithError(c, err)
return
}
if err := f.Close(); err != nil {
_ = WithError(c, err)
return
}
checksum := hex.EncodeToString(h.Sum(nil))
file, err := os.Open(s.Archiver.Path())
// Stream the file to the client.
f, err = os.Open(archivePath)
if err != nil {
WithError(c, err)
_ = WithError(c, err)
return
}
defer file.Close()
defer f.Close()
c.Header("X-Checksum", checksum)
c.Header("X-Mime-Type", st.Mimetype)
c.Header("X-Mime-Type", "application/tar+gzip")
c.Header("Content-Length", strconv.Itoa(int(st.Size())))
c.Header("Content-Disposition", "attachment; filename="+strconv.Quote(s.Archiver.Name()))
c.Header("Content-Disposition", "attachment; filename="+strconv.Quote(s.Id()+".tar.gz"))
c.Header("Content-Type", "application/octet-stream")
bufio.NewReader(file).WriteTo(c.Writer)
_, _ = bufio.NewReader(f).WriteTo(c.Writer)
}
func postServerArchive(c *gin.Context) {
@@ -164,8 +184,13 @@ func postServerArchive(c *gin.Context) {
return
}
// Create an archive of the entire server's data directory.
a := &filesystem.Archive{
BasePath: s.Filesystem().Path(),
}
// Attempt to get an archive of the server.
if err := s.Archiver.Archive(); err != nil {
if err := a.Create(getArchivePath(s.Id())); err != nil {
sendTransferLog("An error occurred while archiving the server: " + err.Error())
l.WithField("error", err).Error("failed to get transfer archive for server")
return
@@ -227,7 +252,7 @@ func (str serverTransferRequest) downloadArchive() (*http.Response, error) {
// Returns the path to the local archive on the system.
func (str serverTransferRequest) path() string {
return filepath.Join(config.Get().System.ArchiveDirectory, str.ServerID+".tar.gz")
return getArchivePath(str.ServerID)
}
// Creates the archive location on this machine by first checking that the required file
@@ -260,17 +285,16 @@ func (str serverTransferRequest) removeArchivePath() {
// expected value from the transfer request. The string value returned is the computed
// checksum on the system.
func (str serverTransferRequest) verifyChecksum(matches string) (bool, string, error) {
file, err := os.Open(str.path())
f, err := os.Open(str.path())
if err != nil {
return false, "", err
}
defer file.Close()
hash := sha256.New()
buf := make([]byte, 1024*4)
if _, err := io.CopyBuffer(hash, file, buf); err != nil {
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, bufio.NewReader(f)); err != nil {
return false, "", err
}
checksum := hex.EncodeToString(hash.Sum(nil))
checksum := hex.EncodeToString(h.Sum(nil))
return checksum == matches, checksum, nil
}
@@ -362,7 +386,7 @@ func postTransfer(c *gin.Context) {
return
}
defer res.Body.Close()
if res.StatusCode != 200 {
if res.StatusCode != http.StatusOK {
data.log().WithField("error", err).WithField("status", res.StatusCode).Error("unexpected error response from transfer endpoint")
return
}

View File

@@ -1,120 +0,0 @@
package server
import (
"crypto/sha256"
"encoding/hex"
"io"
"io/ioutil"
"os"
"path/filepath"
"emperror.dev/errors"
"github.com/mholt/archiver/v3"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/server/filesystem"
)
// Archiver represents a Server Archiver.
type Archiver struct {
Server *Server
}
// Path returns the path to the server's archive.
func (a *Archiver) Path() string {
return filepath.Join(config.Get().System.ArchiveDirectory, a.Name())
}
// Name returns the name of the server's archive.
func (a *Archiver) Name() string {
return a.Server.Id() + ".tar.gz"
}
// Exists returns a boolean based off if the archive exists.
func (a *Archiver) Exists() bool {
if _, err := os.Stat(a.Path()); os.IsNotExist(err) {
return false
}
return true
}
// Stat stats the archive file.
func (a *Archiver) Stat() (*filesystem.Stat, error) {
s, err := os.Stat(a.Path())
if err != nil {
return nil, err
}
return &filesystem.Stat{
FileInfo: s,
Mimetype: "application/tar+gzip",
}, nil
}
// Archive creates an archive of the server and deletes the previous one.
func (a *Archiver) Archive() error {
path := a.Server.Filesystem().Path()
// Get the list of root files and directories to archive.
var files []string
fileInfo, err := ioutil.ReadDir(path)
if err != nil {
return err
}
for _, file := range fileInfo {
f := filepath.Join(path, file.Name())
// If the file is a symlink we cannot safely assume that the result of a filepath.Join() will be
// a safe destination. We need to check if the file is a symlink, and if so pass off to the SafePath
// function to resolve it to the final destination.
//
// ioutil.ReadDir() calls Lstat, so this will work correctly. If it did not call Lstat, but rather
// just did a normal Stat call, this would fail since that would be looking at the symlink destination
// and not the actual file in this listing.
if file.Mode()&os.ModeSymlink != 0 {
f, err = a.Server.Filesystem().SafePath(filepath.Join(path, file.Name()))
if err != nil {
return err
}
}
files = append(files, f)
}
if err := a.DeleteIfExists(); err != nil {
return err
}
return archiver.NewTarGz().Archive(files, a.Path())
}
// DeleteIfExists deletes the archive if it exists.
func (a *Archiver) DeleteIfExists() error {
if _, err := a.Stat(); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
return errors.WithMessage(os.Remove(a.Path()), "archiver: failed to delete archive from system")
}
// Checksum computes a SHA256 checksum of the server's archive.
func (a *Archiver) Checksum() (string, error) {
file, err := os.Open(a.Path())
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
buf := make([]byte, 1024*4)
if _, err := io.CopyBuffer(hash, file, buf); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}

View File

@@ -66,7 +66,7 @@ func (s *Server) Backup(b backup.BackupInterface) error {
}
}
ad, err := b.Generate(s.Filesystem().Path(), ignored)
ad, err := b.Generate(s.Context(), s.Filesystem().Path(), ignored)
if err != nil {
if err := s.notifyPanelOfBackup(b.Identifier(), &backup.ArchiveDetails{}, false); err != nil {
s.Log().WithFields(log.Fields{
@@ -150,7 +150,7 @@ func (s *Server) RestoreBackup(b backup.BackupInterface, reader io.ReadCloser) (
// Attempt to restore the backup to the server by running through each entry
// in the file one at a time and writing them to the disk.
s.Log().Debug("starting file writing process for backup restoration")
err = b.Restore(reader, func(file string, r io.Reader) error {
err = b.Restore(s.Context(), reader, func(file string, r io.Reader) error {
s.Events().Publish(DaemonMessageEvent, "(restoring): "+file)
return s.Filesystem().Writefile(file, r)
})

View File

@@ -1,16 +1,18 @@
package backup
import (
"context"
"crypto/sha1"
"encoding/hex"
"io"
"os"
"path"
"sync"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/remote"
"golang.org/x/sync/errgroup"
)
type AdapterType string
@@ -24,20 +26,37 @@ const (
// and remote backups allowing the files to be restored.
type RestoreCallback func(file string, r io.Reader) error
type ArchiveDetails struct {
Checksum string `json:"checksum"`
ChecksumType string `json:"checksum_type"`
Size int64 `json:"size"`
}
// ToRequest returns a request object.
func (ad *ArchiveDetails) ToRequest(successful bool) remote.BackupRequest {
return remote.BackupRequest{
Checksum: ad.Checksum,
ChecksumType: ad.ChecksumType,
Size: ad.Size,
Successful: successful,
}
// noinspection GoNameStartsWithPackageName
type BackupInterface interface {
// SetClient sets the API request client on the backup interface.
SetClient(c remote.Client)
// Identifier returns the UUID of this backup as tracked by the panel
// instance.
Identifier() string
// WithLogContext attaches additional context to the log output for this
// backup.
WithLogContext(map[string]interface{})
// Generate creates a backup in whatever the configured source for the
// specific implementation is.
Generate(ctx context.Context, basePath string, ignore string) (*ArchiveDetails, error)
// Ignored returns the ignored files for this backup instance.
Ignored() string
// Checksum returns a SHA1 checksum for the generated backup.
Checksum() ([]byte, error)
// Size returns the size of the generated backup.
Size() (int64, error)
// Path returns the path to the backup on the machine. This is not always
// the final storage location of the backup, simply the location we're using
// to store it until it is moved to the final spot.
Path() string
// Details returns details about the archive.
Details(ctx context.Context) (*ArchiveDetails, error)
// Remove removes a backup file.
Remove() error
// Restore is called when a backup is ready to be restored to the disk from
// the given source. Not every backup implementation will support this nor
// will every implementation require a reader be provided.
Restore(ctx context.Context, reader io.Reader, callback RestoreCallback) error
}
type Backup struct {
@@ -54,39 +73,6 @@ type Backup struct {
logContext map[string]interface{}
}
// noinspection GoNameStartsWithPackageName
type BackupInterface interface {
// SetClient sets the API request client on the backup interface.
SetClient(c remote.Client)
// Identifier returns the UUID of this backup as tracked by the panel
// instance.
Identifier() string
// WithLogContext attaches additional context to the log output for this
// backup.
WithLogContext(map[string]interface{})
// Generate creates a backup in whatever the configured source for the
// specific implementation is.
Generate(string, string) (*ArchiveDetails, error)
// Ignored returns the ignored files for this backup instance.
Ignored() string
// Checksum returns a SHA1 checksum for the generated backup.
Checksum() ([]byte, error)
// Size returns the size of the generated backup.
Size() (int64, error)
// Path returns the path to the backup on the machine. This is not always
// the final storage location of the backup, simply the location we're using
// to store it until it is moved to the final spot.
Path() string
// Details returns details about the archive.
Details() *ArchiveDetails
// Remove removes a backup file.
Remove() error
// Restore is called when a backup is ready to be restored to the disk from
// the given source. Not every backup implementation will support this nor
// will every implementation require a reader be provided.
Restore(reader io.Reader, callback RestoreCallback) error
}
func (b *Backup) SetClient(c remote.Client) {
b.client = c
}
@@ -95,12 +81,12 @@ func (b *Backup) Identifier() string {
return b.Uuid
}
// Returns the path for this specific backup.
// Path returns the path for this specific backup.
func (b *Backup) Path() string {
return path.Join(config.Get().System.BackupDirectory, b.Identifier()+".tar.gz")
}
// Return the size of the generated backup.
// Size returns the size of the generated backup.
func (b *Backup) Size() (int64, error) {
st, err := os.Stat(b.Path())
if err != nil {
@@ -110,7 +96,7 @@ func (b *Backup) Size() (int64, error) {
return st.Size(), nil
}
// Returns the SHA256 checksum of a backup.
// Checksum returns the SHA256 checksum of a backup.
func (b *Backup) Checksum() ([]byte, error) {
h := sha1.New()
@@ -128,51 +114,34 @@ func (b *Backup) Checksum() ([]byte, error) {
return h.Sum(nil), nil
}
// Returns details of the archive by utilizing two go-routines to get the checksum and
// the size of the archive.
func (b *Backup) Details() *ArchiveDetails {
wg := sync.WaitGroup{}
wg.Add(2)
// Details returns both the checksum and size of the archive currently stored on
// the disk to the caller.
func (b *Backup) Details(ctx context.Context) (*ArchiveDetails, error) {
ad := ArchiveDetails{ChecksumType: "sha1"}
g, ctx := errgroup.WithContext(ctx)
l := log.WithField("backup_id", b.Uuid)
var checksum string
// Calculate the checksum for the file.
go func() {
defer wg.Done()
l.Info("computing checksum for backup...")
g.Go(func() error {
resp, err := b.Checksum()
if err != nil {
log.WithFields(log.Fields{
"backup": b.Identifier(),
"error": err,
}).Error("failed to calculate checksum for backup")
return
return err
}
ad.Checksum = hex.EncodeToString(resp)
return nil
})
checksum = hex.EncodeToString(resp)
l.WithField("checksum", checksum).Info("computed checksum for backup")
}()
var sz int64
go func() {
defer wg.Done()
if s, err := b.Size(); err != nil {
return
} else {
sz = s
g.Go(func() error {
s, err := b.Size()
if err != nil {
return err
}
}()
ad.Size = s
return nil
})
wg.Wait()
return &ArchiveDetails{
Checksum: checksum,
ChecksumType: "sha1",
Size: sz,
if err := g.Wait(); err != nil {
return nil, errors.WithStackDepth(err, 1)
}
return &ad, nil
}
func (b *Backup) Ignored() string {
@@ -188,3 +157,19 @@ func (b *Backup) log() *log.Entry {
}
return l
}
type ArchiveDetails struct {
Checksum string `json:"checksum"`
ChecksumType string `json:"checksum_type"`
Size int64 `json:"size"`
}
// ToRequest returns a request object.
func (ad *ArchiveDetails) ToRequest(successful bool) remote.BackupRequest {
return remote.BackupRequest{
Checksum: ad.Checksum,
ChecksumType: ad.ChecksumType,
Size: ad.Size,
Successful: successful,
}
}

View File

@@ -1,13 +1,15 @@
package backup
import (
"errors"
"context"
"io"
"os"
"emperror.dev/errors"
"github.com/pterodactyl/wings/server/filesystem"
"github.com/mholt/archiver/v3"
"github.com/pterodactyl/wings/remote"
"github.com/pterodactyl/wings/system"
)
type LocalBackup struct {
@@ -55,32 +57,40 @@ func (b *LocalBackup) WithLogContext(c map[string]interface{}) {
// Generate generates a backup of the selected files and pushes it to the
// defined location for this instance.
func (b *LocalBackup) Generate(basePath, ignore string) (*ArchiveDetails, error) {
a := &Archive{
func (b *LocalBackup) Generate(ctx context.Context, basePath, ignore string) (*ArchiveDetails, error) {
a := &filesystem.Archive{
BasePath: basePath,
Ignore: ignore,
}
b.log().Info("creating backup for server...")
b.log().WithField("path", b.Path()).Info("creating backup for server")
if err := a.Create(b.Path()); err != nil {
return nil, err
}
b.log().Info("created backup successfully")
return b.Details(), nil
ad, err := b.Details(ctx)
if err != nil {
return nil, errors.WrapIf(err, "backup: failed to get archive details for local backup")
}
return ad, nil
}
// Restore will walk over the archive and call the callback function for each
// file encountered.
func (b *LocalBackup) Restore(_ io.Reader, callback RestoreCallback) error {
func (b *LocalBackup) Restore(ctx context.Context, _ io.Reader, callback RestoreCallback) error {
return archiver.Walk(b.Path(), func(f archiver.File) error {
select {
case <-ctx.Done():
// Stop walking if the context is canceled.
return archiver.ErrStopWalk
default:
{
if f.IsDir() {
return nil
}
name, err := system.ExtractArchiveSourceName(f, "/")
if err != nil {
return err
return callback(filesystem.ExtractNameFromArchive(f), f)
}
}
return callback(name, f)
})
}

View File

@@ -9,6 +9,11 @@ import (
"net/http"
"os"
"strconv"
"time"
"emperror.dev/errors"
"github.com/cenkalti/backoff/v4"
"github.com/pterodactyl/wings/server/filesystem"
"github.com/juju/ratelimit"
"github.com/pterodactyl/wings/config"
@@ -44,15 +49,15 @@ func (s *S3Backup) WithLogContext(c map[string]interface{}) {
// Generate creates a new backup on the disk, moves it into the S3 bucket via
// the provided presigned URL, and then deletes the backup from the disk.
func (s *S3Backup) Generate(basePath, ignore string) (*ArchiveDetails, error) {
func (s *S3Backup) Generate(ctx context.Context, basePath, ignore string) (*ArchiveDetails, error) {
defer s.Remove()
a := &Archive{
a := &filesystem.Archive{
BasePath: basePath,
Ignore: ignore,
}
s.log().Info("creating backup for server...")
s.log().WithField("path", s.Path()).Info("creating backup for server")
if err := a.Create(s.Path()); err != nil {
return nil, err
}
@@ -60,29 +65,65 @@ func (s *S3Backup) Generate(basePath, ignore string) (*ArchiveDetails, error) {
rc, err := os.Open(s.Path())
if err != nil {
return nil, err
return nil, errors.Wrap(err, "backup: could not read archive from disk")
}
defer rc.Close()
if err := s.generateRemoteRequest(rc); err != nil {
if err := s.generateRemoteRequest(ctx, rc); err != nil {
return nil, err
}
return s.Details(), nil
ad, err := s.Details(ctx)
if err != nil {
return nil, errors.WrapIf(err, "backup: failed to get archive details after upload")
}
return ad, nil
}
// Reader provides a wrapper around an existing io.Reader
// but implements io.Closer in order to satisfy an io.ReadCloser.
type Reader struct {
io.Reader
}
func (Reader) Close() error {
// Restore will read from the provided reader assuming that it is a gzipped
// tar reader. When a file is encountered in the archive the callback function
// will be triggered. If the callback returns an error the entire process is
// stopped, otherwise this function will run until all files have been written.
//
// This restoration uses a workerpool to use up to the number of CPUs available
// on the machine when writing files to the disk.
func (s *S3Backup) Restore(ctx context.Context, r io.Reader, callback RestoreCallback) error {
reader := r
// Steal the logic we use for making backups which will be applied when restoring
// this specific backup. This allows us to prevent overloading the disk unintentionally.
if writeLimit := int64(config.Get().System.Backups.WriteLimit * 1024 * 1024); writeLimit > 0 {
reader = ratelimit.Reader(r, ratelimit.NewBucketWithRate(float64(writeLimit), writeLimit))
}
gr, err := gzip.NewReader(reader)
if err != nil {
return err
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
select {
case <-ctx.Done():
return nil
default:
// Do nothing, fall through to the next block of code in this loop.
}
header, err := tr.Next()
if err != nil {
if err == io.EOF {
break
}
return err
}
if header.Typeflag == tar.TypeReg {
if err := callback(header.Name, tr); err != nil {
return err
}
}
}
return nil
}
// Generates the remote S3 request and begins the upload.
func (s *S3Backup) generateRemoteRequest(rc io.ReadCloser) error {
func (s *S3Backup) generateRemoteRequest(ctx context.Context, rc io.ReadCloser) error {
defer rc.Close()
s.log().Debug("attempting to get size of backup...")
@@ -100,37 +141,7 @@ func (s *S3Backup) generateRemoteRequest(rc io.ReadCloser) error {
s.log().Debug("got S3 upload urls from the Panel")
s.log().WithField("parts", len(urls.Parts)).Info("attempting to upload backup to s3 endpoint...")
handlePart := func(part string, size int64) (string, error) {
r, err := http.NewRequest(http.MethodPut, part, nil)
if err != nil {
return "", err
}
r.ContentLength = size
r.Header.Add("Content-Length", strconv.Itoa(int(size)))
r.Header.Add("Content-Type", "application/x-gzip")
// Limit the reader to the size of the part.
r.Body = Reader{Reader: io.LimitReader(rc, size)}
// This http request can block forever due to it not having a timeout,
// but we are uploading up to 5GB of data, so there is not really
// a good way to handle a timeout on this.
res, err := http.DefaultClient.Do(r)
if err != nil {
return "", err
}
defer res.Body.Close()
// Handle non-200 status codes.
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to put S3 object part, %d:%s", res.StatusCode, res.Status)
}
// Get the ETag from the uploaded part, this should be sent with the CompleteMultipartUpload request.
return res.Header.Get("ETag"), nil
}
uploader := newS3FileUploader(rc)
for i, part := range urls.Parts {
// Get the size for the current part.
var partSize int64
@@ -143,7 +154,7 @@ func (s *S3Backup) generateRemoteRequest(rc io.ReadCloser) error {
}
// Attempt to upload the part.
if _, err := handlePart(part, partSize); err != nil {
if _, err := uploader.uploadPart(ctx, part, partSize); err != nil {
s.log().WithField("part_id", i+1).WithError(err).Warn("failed to upload part")
return err
}
@@ -156,39 +167,97 @@ func (s *S3Backup) generateRemoteRequest(rc io.ReadCloser) error {
return nil
}
// Restore will read from the provided reader assuming that it is a gzipped
// tar reader. When a file is encountered in the archive the callback function
// will be triggered. If the callback returns an error the entire process is
// stopped, otherwise this function will run until all files have been written.
type s3FileUploader struct {
io.ReadCloser
client *http.Client
}
// newS3FileUploader returns a new file uploader instance.
func newS3FileUploader(file io.ReadCloser) *s3FileUploader {
return &s3FileUploader{
ReadCloser: file,
// We purposefully use a super high timeout on this request since we need to upload
// a 5GB file. This assumes at worst a 10Mbps connection for uploading. While technically
// you could go slower we're targeting mostly hosted servers that should have 100Mbps
// connections anyways.
client: &http.Client{Timeout: time.Hour * 2},
}
}
// backoff returns a new expoential backoff implementation using a context that
// will also stop the backoff if it is canceled.
func (fu *s3FileUploader) backoff(ctx context.Context) backoff.BackOffContext {
b := backoff.NewExponentialBackOff()
b.Multiplier = 2
b.MaxElapsedTime = time.Minute
return backoff.WithContext(b, ctx)
}
// uploadPart attempts to upload a given S3 file part to the S3 system. If a
// 5xx error is returned from the endpoint this will continue with an exponential
// backoff to try and successfully upload the part.
//
// This restoration uses a workerpool to use up to the number of CPUs available
// on the machine when writing files to the disk.
func (s *S3Backup) Restore(r io.Reader, callback RestoreCallback) error {
reader := r
// Steal the logic we use for making backups which will be applied when restoring
// this specific backup. This allows us to prevent overloading the disk unintentionally.
if writeLimit := int64(config.Get().System.Backups.WriteLimit * 1024 * 1024); writeLimit > 0 {
reader = ratelimit.Reader(r, ratelimit.NewBucketWithRate(float64(writeLimit), writeLimit))
}
gr, err := gzip.NewReader(reader)
// Once uploaded the ETag is returned to the caller.
func (fu *s3FileUploader) uploadPart(ctx context.Context, part string, size int64) (string, error) {
r, err := http.NewRequestWithContext(ctx, http.MethodPut, part, nil)
if err != nil {
return err
return "", errors.Wrap(err, "backup: could not create request for S3")
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
r.ContentLength = size
r.Header.Add("Content-Length", strconv.Itoa(int(size)))
r.Header.Add("Content-Type", "application/x-gzip")
// Limit the reader to the size of the part.
r.Body = Reader{Reader: io.LimitReader(fu.ReadCloser, size)}
var etag string
err = backoff.Retry(func() error {
res, err := fu.client.Do(r)
if err != nil {
if err == io.EOF {
break
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return backoff.Permanent(err)
}
// Don't use a permanent error here, if there is a temporary resolution error with
// the URL due to DNS issues we want to keep re-trying.
return errors.Wrap(err, "backup: S3 HTTP request failed")
}
_ = res.Body.Close()
if res.StatusCode != http.StatusOK {
err := errors.New(fmt.Sprintf("backup: failed to put S3 object: [HTTP/%d] %s", res.StatusCode, res.Status))
// Only attempt a backoff retry if this error is because of a 5xx error from
// the S3 endpoint. Any 4xx error should be treated as an error that a retry
// would not fix.
if res.StatusCode >= http.StatusInternalServerError {
return err
}
if header.Typeflag == tar.TypeReg {
if err := callback(header.Name, tr); err != nil {
return err
return backoff.Permanent(err)
}
// Get the ETag from the uploaded part, this should be sent with the
// CompleteMultipartUpload request.
etag = res.Header.Get("ETag")
return nil
}, fu.backoff(ctx))
if err != nil {
if v, ok := err.(*backoff.PermanentError); ok {
return "", v.Unwrap()
}
return "", err
}
return etag, nil
}
// Reader provides a wrapper around an existing io.Reader
// but implements io.Closer in order to satisfy an io.ReadCloser.
type Reader struct {
io.Reader
}
func (Reader) Close() error {
return nil
}

View File

@@ -8,7 +8,7 @@ import (
type EggConfiguration struct {
// The internal UUID of the Egg on the Panel.
ID string
ID string `json:"id"`
// Maintains a list of files that are blacklisted for opening/editing/downloading
// or basically any type of access on the server by any user. This is NOT the same
@@ -43,7 +43,6 @@ type Configuration struct {
Build environment.Limits `json:"build"`
CrashDetectionEnabled bool `default:"true" json:"enabled" yaml:"enabled"`
Mounts []Mount `json:"mounts"`
Resources ResourceUsage `json:"resources"`
Egg EggConfiguration `json:"egg,omitempty"`
Container struct {

View File

@@ -13,6 +13,11 @@ import (
"github.com/pterodactyl/wings/system"
)
// appName is a local cache variable to avoid having to make expensive copies of
// the configuration every time we need to send output along to the websocket for
// a server.
var appName string
var ErrTooMuchConsoleData = errors.New("console is outputting too much data")
type ConsoleThrottler struct {
@@ -122,11 +127,14 @@ func (s *Server) Throttler() *ConsoleThrottler {
return s.throttler
}
// Sends output to the server console formatted to appear correctly as being sent
// from Wings.
// PublishConsoleOutputFromDaemon sends output to the server console formatted
// to appear correctly as being sent from Wings.
func (s *Server) PublishConsoleOutputFromDaemon(data string) {
if appName == "" {
appName = config.Get().AppName
}
s.Events().Publish(
ConsoleOutputEvent,
colorstring.Color(fmt.Sprintf("[yellow][bold][Pterodactyl Daemon]:[default] %s", data)),
colorstring.Color(fmt.Sprintf("[yellow][bold][%s Daemon]:[default] %s", appName, data)),
)
}

View File

@@ -9,6 +9,7 @@ var (
ErrSuspended = errors.New("server is currently in a suspended state")
ErrServerIsInstalling = errors.New("server is currently installing")
ErrServerIsTransferring = errors.New("server is currently being transferred")
ErrServerIsRestoring = errors.New("server is currently being restored")
)
type crashTooFrequent struct {

View File

@@ -1,4 +1,4 @@
package backup
package filesystem
import (
"archive/tar"

View File

@@ -1,6 +1,9 @@
package filesystem
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"os"
"path"
@@ -9,9 +12,8 @@ import (
"sync/atomic"
"time"
"emperror.dev/errors"
"github.com/mholt/archiver/v3"
"github.com/pterodactyl/wings/server/backup"
"github.com/pterodactyl/wings/system"
)
// CompressFiles compresses all of the files matching the given paths in the
@@ -39,7 +41,7 @@ func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, er
return nil, err
}
a := &backup.Archive{BasePath: cleanedRootDir, Files: cleaned}
a := &Archive{BasePath: cleanedRootDir, Files: cleaned}
d := path.Join(
cleanedRootDir,
fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")),
@@ -87,13 +89,13 @@ func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) er
// Walk over the archive and figure out just how large the final output would be from unarchiving it.
err = archiver.Walk(source, func(f archiver.File) error {
if atomic.AddInt64(&size, f.Size())+dirSize > fs.MaxDisk() {
return &Error{code: ErrCodeDiskSpace}
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
})
if err != nil {
if strings.HasPrefix(err.Error(), "format ") {
return &Error{code: ErrCodeUnknownArchive}
if IsUnknownArchiveFormatError(err) {
return newFilesystemError(ErrCodeUnknownArchive, err)
}
return err
}
@@ -112,7 +114,7 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
}
// Ensure that the source archive actually exists on the system.
if _, err := os.Stat(source); err != nil {
return err
return errors.WithStack(err)
}
// Walk all of the files in the archiver file and write them to the disk. If any
@@ -122,26 +124,53 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
if f.IsDir() {
return nil
}
name, err := system.ExtractArchiveSourceName(f, dir)
if err != nil {
return WrapError(err, filepath.Join(dir, f.Name()))
}
p := filepath.Join(dir, name)
p := filepath.Join(dir, ExtractNameFromArchive(f))
// If it is ignored, just don't do anything with the file and skip over it.
if err := fs.IsIgnored(p); err != nil {
return nil
}
if err := fs.Writefile(p, f); err != nil {
return &Error{code: ErrCodeUnknownError, err: err, resolved: source}
return wrapError(err, source)
}
return nil
})
if err != nil {
if strings.HasPrefix(err.Error(), "format ") {
return &Error{code: ErrCodeUnknownArchive}
if IsUnknownArchiveFormatError(err) {
return newFilesystemError(ErrCodeUnknownArchive, err)
}
return err
}
return nil
}
// ExtractNameFromArchive looks at an archive file to try and determine the name
// for a given element in an archive. Because of... who knows why, each file type
// uses different methods to determine the file name.
//
// If there is a archiver.File#Sys() value present we will try to use the name
// present in there, otherwise falling back to archiver.File#Name() if all else
// fails. Without this logic present, some archive types such as zip/tars/etc.
// will write all of the files to the base directory, rather than the nested
// directory that is expected.
//
// For files like ".rar" types, there is no f.Sys() value present, and the value
// of archiver.File#Name() will be what you need.
func ExtractNameFromArchive(f archiver.File) string {
sys := f.Sys()
// Some archive types won't have a value returned when you call f.Sys() on them,
// such as ".rar" archives for example. In those cases the only thing you can do
// is hope that "f.Name()" is actually correct for them.
if sys == nil {
return f.Name()
}
switch s := sys.(type) {
case *tar.Header:
return s.Name
case *gzip.Header:
return s.Name
case *zip.FileHeader:
return s.Name
default:
return f.Name()
}
}

View File

@@ -0,0 +1,55 @@
package filesystem
import (
"io/ioutil"
"sync/atomic"
"testing"
. "github.com/franela/goblin"
)
// Given an archive named test.{ext}, with the following file structure:
// test/
// |──inside/
// |────finside.txt
// |──outside.txt
// this test will ensure that it's being decompressed as expected
func TestFilesystem_DecompressFile(t *testing.T) {
g := Goblin(t)
fs, rfs := NewFs()
g.Describe("Decompress", func() {
for _, ext := range []string{"zip", "rar", "tar", "tar.gz"} {
g.It("can decompress a "+ext, func() {
// copy the file to the new FS
c, err := ioutil.ReadFile("./testdata/test." + ext)
g.Assert(err).IsNil()
err = rfs.CreateServerFile("./test."+ext, c)
g.Assert(err).IsNil()
// decompress
err = fs.DecompressFile("/", "test."+ext)
g.Assert(err).IsNil()
// make sure everything is where it is supposed to be
_, err = rfs.StatServerFile("test/outside.txt")
g.Assert(err).IsNil()
st, err := rfs.StatServerFile("test/inside")
g.Assert(err).IsNil()
g.Assert(st.IsDir()).IsTrue()
_, err = rfs.StatServerFile("test/inside/finside.txt")
g.Assert(err).IsNil()
g.Assert(st.IsDir()).IsTrue()
})
}
g.AfterEach(func() {
rfs.reset()
atomic.StoreInt64(&fs.diskUsed, 0)
atomic.StoreInt64(&fs.diskLimit, 0)
})
})
}

View File

@@ -1,13 +1,14 @@
package filesystem
import (
"emperror.dev/errors"
"github.com/apex/log"
"github.com/karrick/godirwalk"
"sync"
"sync/atomic"
"syscall"
"time"
"emperror.dev/errors"
"github.com/apex/log"
"github.com/karrick/godirwalk"
)
type SpaceCheckingOpts struct {
@@ -48,7 +49,7 @@ func (fs *Filesystem) SetDiskLimit(i int64) {
// no space, rather than a boolean value.
func (fs *Filesystem) HasSpaceErr(allowStaleValue bool) error {
if !fs.HasSpaceAvailable(allowStaleValue) {
return &Error{code: ErrCodeDiskSpace}
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
}
@@ -200,16 +201,13 @@ func (fs *Filesystem) HasSpaceFor(size int64) error {
if fs.MaxDisk() == 0 {
return nil
}
s, err := fs.DiskUsage(true)
if err != nil {
return err
}
if (s + size) > fs.MaxDisk() {
return &Error{code: ErrCodeDiskSpace}
return newFilesystemError(ErrCodeDiskSpace, nil)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"emperror.dev/errors"
"github.com/apex/log"
@@ -34,6 +35,14 @@ type Error struct {
path string
}
// newFilesystemError returns a new error instance with a stack trace associated.
func newFilesystemError(code ErrorCode, err error) error {
if err != nil {
return errors.WithStackDepth(&Error{code: code, err: err}, 1)
}
return errors.WithStackDepth(&Error{code: code}, 1)
}
// Code returns the ErrorCode for this specific error instance.
func (e *Error) Code() ErrorCode {
return e.code
@@ -63,13 +72,13 @@ func (e *Error) Error() string {
case ErrCodeUnknownError:
fallthrough
default:
return fmt.Sprintf("filesystem: an error occurred: %s", e.Cause())
return fmt.Sprintf("filesystem: an error occurred: %s", e.Unwrap())
}
}
// Cause returns the underlying cause of this filesystem error. In some causes
// Unwrap returns the underlying cause of this filesystem error. In some causes
// there may not be a cause present, in which case nil will be returned.
func (e *Error) Cause() error {
func (e *Error) Unwrap() error {
return e.err
}
@@ -113,20 +122,26 @@ func IsErrorCode(err error, code ErrorCode) bool {
return false
}
// NewBadPathResolution returns a new BadPathResolution error.
func NewBadPathResolution(path string, resolved string) *Error {
return &Error{code: ErrCodePathResolution, path: path, resolved: resolved}
// IsUnknownArchiveFormatError checks if the error is due to the archive being
// in an unexpected file format.
func IsUnknownArchiveFormatError(err error) bool {
if err != nil && strings.HasPrefix(err.Error(), "format ") {
return true
}
return false
}
// WrapError wraps the provided error as a Filesystem error and attaches the
// NewBadPathResolution returns a new BadPathResolution error.
func NewBadPathResolution(path string, resolved string) error {
return errors.WithStackDepth(&Error{code: ErrCodePathResolution, path: path, resolved: resolved}, 1)
}
// wrapError wraps the provided error as a Filesystem error and attaches the
// provided resolved source to it. If the error is already a Filesystem error
// no action is taken.
func WrapError(err error, resolved string) *Error {
if err == nil {
return nil
func wrapError(err error, resolved string) error {
if err == nil || IsFilesystemError(err) {
return err
}
if IsFilesystemError(err) {
return err.(*Error)
}
return &Error{code: ErrCodeUnknownError, err: err, resolved: resolved}
return errors.WithStackDepth(&Error{code: ErrCodeUnknownError, err: err, resolved: resolved}, 1)
}

View File

@@ -1,13 +1,45 @@
package filesystem
import (
. "github.com/franela/goblin"
"io"
"testing"
"emperror.dev/errors"
. "github.com/franela/goblin"
)
type stackTracer interface {
StackTrace() errors.StackTrace
}
func TestFilesystem_PathResolutionError(t *testing.T) {
g := Goblin(t)
g.Describe("NewFilesystemError", func() {
g.It("includes a stack trace for the error", func() {
err := newFilesystemError(ErrCodeUnknownError, nil)
_, ok := err.(stackTracer)
g.Assert(ok).IsTrue()
})
g.It("properly wraps the underlying error cause", func() {
underlying := io.EOF
err := newFilesystemError(ErrCodeUnknownError, underlying)
_, ok := err.(stackTracer)
g.Assert(ok).IsTrue()
_, ok = err.(*Error)
g.Assert(ok).IsFalse()
fserr, ok := errors.Unwrap(err).(*Error)
g.Assert(ok).IsTrue()
g.Assert(fserr.Unwrap()).IsNotNil()
g.Assert(fserr.Unwrap()).Equal(underlying)
})
})
g.Describe("NewBadPathResolutionError", func() {
g.It("is can detect itself as an error correctly", func() {
err := NewBadPathResolution("foo", "bar")
@@ -18,6 +50,7 @@ func TestFilesystem_PathResolutionError(t *testing.T) {
g.It("returns <empty> if no destination path is provided", func() {
err := NewBadPathResolution("foo", "")
g.Assert(err).IsNotNil()
g.Assert(err.Error()).Equal("filesystem: server path [foo] resolves to a location outside the server root: <empty>")
})
})

View File

@@ -67,7 +67,7 @@ func (fs *Filesystem) File(p string) (*os.File, Stat, error) {
return nil, Stat{}, err
}
if st.IsDir() {
return nil, Stat{}, &Error{code: ErrCodeIsDirectory}
return nil, Stat{}, newFilesystemError(ErrCodeIsDirectory, nil)
}
f, err := os.Open(cleaned)
if err != nil {
@@ -144,7 +144,7 @@ func (fs *Filesystem) Writefile(p string, r io.Reader) error {
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
} else if err == nil {
if stat.IsDir() {
return &Error{code: ErrCodeIsDirectory, resolved: cleaned}
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: cleaned})
}
currentSize = stat.Size()
}

View File

@@ -44,17 +44,21 @@ type rootFs struct {
root string
}
func (rfs *rootFs) CreateServerFile(p string, c string) error {
func (rfs *rootFs) CreateServerFile(p string, c []byte) error {
f, err := os.Create(filepath.Join(rfs.root, "/server", p))
if err == nil {
f.Write([]byte(c))
f.Write(c)
f.Close()
}
return err
}
func (rfs *rootFs) CreateServerFileFromString(p string, c string) error {
return rfs.CreateServerFile(p, []byte(c))
}
func (rfs *rootFs) StatServerFile(p string) (os.FileInfo, error) {
return os.Stat(filepath.Join(rfs.root, "/server", p))
}
@@ -79,7 +83,7 @@ func TestFilesystem_Readfile(t *testing.T) {
buf := &bytes.Buffer{}
g.It("opens a file if it exists on the system", func() {
err := rfs.CreateServerFile("test.txt", "testing")
err := rfs.CreateServerFileFromString("test.txt", "testing")
g.Assert(err).IsNil()
err = fs.Readfile("test.txt", buf)
@@ -103,7 +107,7 @@ func TestFilesystem_Readfile(t *testing.T) {
})
g.It("cannot open a file outside the root directory", func() {
err := rfs.CreateServerFile("/../test.txt", "testing")
err := rfs.CreateServerFileFromString("/../test.txt", "testing")
g.Assert(err).IsNil()
err = fs.Readfile("/../test.txt", buf)
@@ -281,13 +285,13 @@ func TestFilesystem_Rename(t *testing.T) {
g.Describe("Rename", func() {
g.BeforeEach(func() {
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
if err := rfs.CreateServerFileFromString("source.txt", "text content"); err != nil {
panic(err)
}
})
g.It("returns an error if the target already exists", func() {
err := rfs.CreateServerFile("target.txt", "taget content")
err := rfs.CreateServerFileFromString("target.txt", "taget content")
g.Assert(err).IsNil()
err = fs.Rename("source.txt", "target.txt")
@@ -314,7 +318,7 @@ func TestFilesystem_Rename(t *testing.T) {
})
g.It("does not allow renaming from a location outside the root", func() {
err := rfs.CreateServerFile("/../ext-source.txt", "taget content")
err := rfs.CreateServerFileFromString("/../ext-source.txt", "taget content")
err = fs.Rename("/../ext-source.txt", "target.txt")
g.Assert(err).IsNotNil()
@@ -378,7 +382,7 @@ func TestFilesystem_Copy(t *testing.T) {
g.Describe("Copy", func() {
g.BeforeEach(func() {
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
if err := rfs.CreateServerFileFromString("source.txt", "text content"); err != nil {
panic(err)
}
@@ -392,7 +396,7 @@ func TestFilesystem_Copy(t *testing.T) {
})
g.It("should return an error if the source is outside the root", func() {
err := rfs.CreateServerFile("/../ext-source.txt", "text content")
err := rfs.CreateServerFileFromString("/../ext-source.txt", "text content")
err = fs.Copy("../ext-source.txt")
g.Assert(err).IsNotNil()
@@ -403,7 +407,7 @@ func TestFilesystem_Copy(t *testing.T) {
err := os.MkdirAll(filepath.Join(rfs.root, "/nested/in/dir"), 0755)
g.Assert(err).IsNil()
err = rfs.CreateServerFile("/../nested/in/dir/ext-source.txt", "external content")
err = rfs.CreateServerFileFromString("/../nested/in/dir/ext-source.txt", "external content")
g.Assert(err).IsNil()
err = fs.Copy("../nested/in/dir/ext-source.txt")
@@ -464,7 +468,7 @@ func TestFilesystem_Copy(t *testing.T) {
err := os.MkdirAll(filepath.Join(rfs.root, "/server/nested/in/dir"), 0755)
g.Assert(err).IsNil()
err = rfs.CreateServerFile("nested/in/dir/source.txt", "test content")
err = rfs.CreateServerFileFromString("nested/in/dir/source.txt", "test content")
g.Assert(err).IsNil()
err = fs.Copy("nested/in/dir/source.txt")
@@ -492,7 +496,7 @@ func TestFilesystem_Delete(t *testing.T) {
g.Describe("Delete", func() {
g.BeforeEach(func() {
if err := rfs.CreateServerFile("source.txt", "test content"); err != nil {
if err := rfs.CreateServerFileFromString("source.txt", "test content"); err != nil {
panic(err)
}
@@ -500,7 +504,7 @@ func TestFilesystem_Delete(t *testing.T) {
})
g.It("does not delete files outside the root directory", func() {
err := rfs.CreateServerFile("/../ext-source.txt", "external content")
err := rfs.CreateServerFileFromString("/../ext-source.txt", "external content")
err = fs.Delete("../ext-source.txt")
g.Assert(err).IsNotNil()
@@ -544,7 +548,7 @@ func TestFilesystem_Delete(t *testing.T) {
g.Assert(err).IsNil()
for _, s := range sources {
err = rfs.CreateServerFile(s, "test content")
err = rfs.CreateServerFileFromString(s, "test content")
g.Assert(err).IsNil()
}

View File

@@ -20,7 +20,7 @@ func (fs *Filesystem) IsIgnored(paths ...string) error {
return err
}
if fs.denylist.MatchesPath(sp) {
return &Error{code: ErrCodeDenylistFile, path: p, resolved: sp}
return errors.WithStack(&Error{code: ErrCodeDenylistFile, path: p, resolved: sp})
}
}
return nil

View File

@@ -2,11 +2,12 @@ package filesystem
import (
"bytes"
"emperror.dev/errors"
. "github.com/franela/goblin"
"os"
"path/filepath"
"testing"
"emperror.dev/errors"
. "github.com/franela/goblin"
)
func TestFilesystem_Path(t *testing.T) {
@@ -102,7 +103,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
g := Goblin(t)
fs, rfs := NewFs()
if err := rfs.CreateServerFile("/../malicious.txt", "external content"); err != nil {
if err := rfs.CreateServerFileFromString("/../malicious.txt", "external content"); err != nil {
panic(err)
}
@@ -180,7 +181,7 @@ func TestFilesystem_Blocks_Symlinks(t *testing.T) {
})
g.It("cannot rename a file to a location outside the directory root", func() {
rfs.CreateServerFile("my_file.txt", "internal content")
rfs.CreateServerFileFromString("my_file.txt", "internal content")
err := fs.Rename("my_file.txt", "external_dir/my_file.txt")
g.Assert(err).IsNotNil()

BIN
server/filesystem/testdata/test.rar vendored Normal file

Binary file not shown.

BIN
server/filesystem/testdata/test.tar vendored Normal file

Binary file not shown.

BIN
server/filesystem/testdata/test.tar.gz vendored Normal file

Binary file not shown.

BIN
server/filesystem/testdata/test.zip vendored Normal file

Binary file not shown.

View File

@@ -90,13 +90,8 @@ func (s *Server) Reinstall() error {
func (s *Server) internalInstall() error {
script, err := s.client.GetInstallationScript(s.Context(), s.Id())
if err != nil {
if !remote.IsRequestError(err) {
return err
}
return errors.New(err.Error())
}
p, err := NewInstallationProcess(s, &script)
if err != nil {
return err
@@ -151,6 +146,14 @@ func (s *Server) SetTransferring(state bool) {
s.transferring.Store(state)
}
func (s *Server) IsRestoring() bool {
return s.restoring.Load()
}
func (s *Server) SetRestoring(state bool) {
s.restoring.Store(state)
}
// Removes the installer container for the server.
func (ip *InstallationProcess) RemoveContainer() error {
err := ip.client.ContainerRemove(ip.context, ip.Server.Id()+"_installer", types.ContainerRemoveOptions{
@@ -431,6 +434,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
ReadOnly: false,
},
},
Resources: ip.resourceLimits(),
Tmpfs: map[string]string{
"/tmp": "rw,exec,nosuid,size=" + tmpfsSize + "M",
},
@@ -527,19 +531,47 @@ func (ip *InstallationProcess) StreamOutput(ctx context.Context, id string) erro
return nil
}
// Makes a 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 {
err := s.client.SetInstallationStatus(s.Context(), s.Id(), successful)
if err != nil {
if !remote.IsRequestError(err) {
return err
// resourceLimits returns the install container specific resource limits. 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
// intensive installation processes if needed.
//
// This also avoids a server with limits such as 4GB of memory from accidentally
// consuming 2-5x the defined limits during the install process and causing
// system instability.
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.
c := *ip.Server.Config()
cfg := c.Build
if cfg.MemoryLimit < limits.Memory {
cfg.MemoryLimit = limits.Memory
}
// Only apply the CPU limit if neither one is currently set to unlimited. If the
// installer CPU limit is unlimited don't even waste time with the logic, just
// set the config to unlimited for this.
if limits.Cpu == 0 {
cfg.CpuLimit = 0
} else if cfg.CpuLimit != 0 && cfg.CpuLimit < limits.Cpu {
cfg.CpuLimit = limits.Cpu
}
return errors.New(err.Error())
}
resources := cfg.AsContainerResources()
// Explicitly remove the PID limits for the installation container. These scripts are
// defined at an administrative level and users can't manually execute things like a
// fork bomb during this process.
resources.PidsLimit = nil
return nil
return resources
}
// SyncInstallState makes a 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)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/pterodactyl/wings/metrics"
"io"
"io/ioutil"
"os"
@@ -72,6 +73,9 @@ func (m *Manager) Add(s *Server) {
m.mu.Lock()
m.servers = append(m.servers, s)
m.mu.Unlock()
// Add the server to the metrics with a offline status.
metrics.ServerStatus.WithLabelValues(s.Id()).Set(0)
}
// Get returns a single server instance and a boolean value indicating if it was
@@ -117,6 +121,9 @@ func (m *Manager) Remove(filter func(match *Server) bool) {
for _, v := range m.servers {
if !filter(v) {
r = append(r, v)
} else {
// Delete the server from the metric.
metrics.DeleteServer(v.Id())
}
}
m.servers = r
@@ -175,7 +182,6 @@ func (m *Manager) InitServer(data remote.ServerConfigurationResponse) (*Server,
return nil, err
}
s.Archiver = Archiver{Server: s}
s.fs = filesystem.New(filepath.Join(config.Get().System.Data, s.Id()), s.DiskSpace(), s.Config().Egg.FileDenylist)
// Right now we only support a Docker based environment, so I'm going to hard code

View File

@@ -70,6 +70,10 @@ func (s *Server) HandlePowerAction(action PowerAction, waitSeconds ...int) error
return ErrServerIsTransferring
}
if s.IsRestoring() {
return ErrServerIsRestoring
}
if s.powerLock == nil {
s.powerLock = semaphore.NewWeighted(1)
}

View File

@@ -3,6 +3,7 @@ package server
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"sync"
@@ -42,7 +43,6 @@ type Server struct {
crasher CrashHandler
resources ResourceUsage
Archiver Archiver `json:"-"`
Environment environment.ProcessEnvironment `json:"-"`
fs *filesystem.Filesystem
@@ -61,6 +61,7 @@ type Server struct {
// installer process is still running.
installing *system.AtomicBool
transferring *system.AtomicBool
restoring *system.AtomicBool
// The console throttler instance used to control outputs.
throttler *ConsoleThrottler
@@ -80,6 +81,7 @@ func New(client remote.Client) (*Server, error) {
client: client,
installing: system.NewAtomicBool(false),
transferring: system.NewAtomicBool(false),
restoring: system.NewAtomicBool(false),
}
if err := defaults.Set(&s); err != nil {
return nil, errors.Wrap(err, "server: could not set default values for struct")
@@ -142,26 +144,20 @@ func (s *Server) Log() *log.Entry {
return log.WithField("server", s.Id())
}
// Syncs the state of the server on the Panel with Wings. This ensures that we're always
// using the state of the server from the Panel and allows us to not require successful
// API calls to Wings to do things.
// Sync syncs the state of the server on the Panel with Wings. This ensures that
// we're always using the state of the server from the Panel and allows us to
// not require successful API calls to Wings to do things.
//
// This also means mass actions can be performed against servers on the Panel and they
// will automatically sync with Wings when the server is started.
// This also means mass actions can be performed against servers on the Panel
// and they will automatically sync with Wings when the server is started.
func (s *Server) Sync() error {
cfg, err := s.client.GetServerConfiguration(s.Context(), s.Id())
if err != nil {
if !remote.IsRequestError(err) {
return err
}
if err.(*remote.RequestError).Status == "404" {
if err := remote.AsRequestError(err); err != nil && err.StatusCode() == http.StatusNotFound {
return &serverDoesNotExist{}
}
return errors.New(err.Error())
return errors.WithStackIf(err)
}
return s.SyncWithConfiguration(cfg)
}
@@ -304,3 +300,24 @@ func (s *Server) IsRunning() bool {
return st == environment.ProcessRunningState || st == environment.ProcessStartingState
}
// APIResponse is a type returned when requesting details about a single server
// instance on Wings. This includes the information needed by the Panel in order
// to show resource utilization and the current state on this system.
type APIResponse struct {
State string `json:"state"`
IsSuspended bool `json:"is_suspended"`
Utilization ResourceUsage `json:"utilization"`
Configuration Configuration `json:"configuration"`
}
// ToAPIResponse returns the server struct as an API object that can be consumed
// by callers.
func (s *Server) ToAPIResponse() APIResponse {
return APIResponse{
State: s.Environment.State(),
IsSuspended: s.IsSuspended(),
Utilization: s.Proc(),
Configuration: *s.Config(),
}
}

View File

@@ -1,23 +1,18 @@
package system
import (
"archive/tar"
"archive/zip"
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"strings"
"sync"
"time"
"emperror.dev/errors"
"github.com/mholt/archiver/v3"
)
var cr = []byte(" \r")
@@ -41,22 +36,6 @@ func MustInt(v string) int {
return i
}
// ExtractArchiveSourceName looks for the provided archiver.File's name if it is
// a type that is supported, otherwise it returns an error to the caller.
func ExtractArchiveSourceName(f archiver.File, dir string) (name string, err error) {
switch s := f.Sys().(type) {
case *tar.Header:
name = s.Name
case *gzip.Header:
name = s.Name
case *zip.FileHeader:
name = s.Name
default:
err = errors.New(fmt.Sprintf("could not parse underlying data source with type: %s", reflect.TypeOf(s).String()))
}
return name, err
}
func ScanReader(r io.Reader, callback func(line string)) error {
br := bufio.NewReader(r)
// Avoid constantly re-allocating memory when we're flooding lines through this