Compare commits

...

42 Commits

Author SHA1 Message Date
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
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
44 changed files with 1004 additions and 840 deletions

View File

@@ -1,5 +1,44 @@
# Changelog
## 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. |
| [**DeinServerHost**](https://deinserverhost.de/) | DeinServerHost offers Dedicated, vps and Gameservers for many popular Games like Minecraft and Rust in Germany since 2013. |
| [**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 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

@@ -58,7 +58,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())
},
}
@@ -400,7 +400,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 +408,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

@@ -247,6 +247,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
@@ -395,7 +397,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
}
@@ -617,4 +619,4 @@ func getSystemName() (string, error) {
return "", err
}
return release["ID"], nil
}
}

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{
// 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.resources(),
}); 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",
@@ -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,34 @@ 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()
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,
}
}

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

@@ -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
}

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=

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)
for scanner.Scan() {
text := scanner.Text()
if len(text) > 0 && text[0] != '#' {
break
// 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 + "\n")
}
_ = fd.Close()
if err := scanner.Err(); err != nil {
return errors.WithStackIf(err)
}
s.WriteString(text)
s.WriteString("\n")
}
// Close the file.
_ = f2.Close()
// Handle any scanner errors.
if err := scanner.Err(); err != nil {
return 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"
)
@@ -31,11 +33,11 @@ type Client interface {
}
type client struct {
httpClient *http.Client
baseUrl string
tokenId string
token string
attempts int
httpClient *http.Client
baseUrl string
tokenId string
token string
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 {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return backoff.Permanent(err)
}
return errors.WrapIf(err, "http: request creation failed")
}
}
if err != nil {
return nil, errors.WithStack(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)
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()
}
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)
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())
if err := res.BindJSON(&auth); err != nil {
return auth, err
}
err = res.BindJSON(&auth)
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()
if err := res.BindJSON(&data); err != nil {
return data, err
}
err = res.BindJSON(&data)
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,12 +194,8 @@ 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
}
return r.Data, r.Meta, nil
}
}

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

@@ -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 {
@@ -19,7 +21,7 @@ var _ BackupInterface = (*LocalBackup)(nil)
func NewLocal(client remote.Client, uuid string, ignore string) *LocalBackup {
return &LocalBackup{
Backup{
client: client,
client: client,
Uuid: uuid,
Ignore: ignore,
adapter: LocalBackupAdapter,
@@ -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 {
if f.IsDir() {
return nil
select {
case <-ctx.Done():
// Stop walking if the context is canceled.
return archiver.ErrStopWalk
default:
{
if f.IsDir() {
return nil
}
return callback(filesystem.ExtractNameFromArchive(f), f)
}
}
name, err := system.ExtractArchiveSourceName(f, "/")
if err != nil {
return err
}
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)
}
return 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")
}
if header.Typeflag == tar.TypeReg {
if err := callback(header.Name, tr); err != nil {
_ = 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
}
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

@@ -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

@@ -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) {

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())
return err
}
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{
@@ -527,19 +530,10 @@ 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.
// 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 {
err := s.client.SetInstallationStatus(s.Context(), s.Id(), successful)
if err != nil {
if !remote.IsRequestError(err) {
return err
}
return errors.New(err.Error())
}
return nil
return s.client.SetInstallationStatus(s.Context(), s.Id(), successful)
}

View File

@@ -175,7 +175,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