Compare commits
66 Commits
v1.0.0-rc.
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e59e6928 | ||
|
|
e02c197585 | ||
|
|
d9109cbf5a | ||
|
|
6bc8b1a567 | ||
|
|
244640d0c1 | ||
|
|
e3e89a2ecc | ||
|
|
90ae815b1d | ||
|
|
1fc15e82f1 | ||
|
|
3a83f65f27 | ||
|
|
37b09255d5 | ||
|
|
b92fab83c8 | ||
|
|
367fdfad54 | ||
|
|
ee460686d6 | ||
|
|
9b7c0fb7f3 | ||
|
|
0f7bb1a371 | ||
|
|
de30e2fcc9 | ||
|
|
a0fa5a94b6 | ||
|
|
3655b172f3 | ||
|
|
4748d7cb29 | ||
|
|
a20861fa8e | ||
|
|
62d0c7fa24 | ||
|
|
0b9ad3de05 | ||
|
|
3391d5803e | ||
|
|
a3cb1b5ae0 | ||
|
|
fb24ad58b4 | ||
|
|
ae5005baa3 | ||
|
|
f79868ff6c | ||
|
|
60211271b2 | ||
|
|
2cc06e0528 | ||
|
|
6cbcfece8e | ||
|
|
7646c27c5a | ||
|
|
7624502ec4 | ||
|
|
bf1233def4 | ||
|
|
1a3ba9efca | ||
|
|
897c4869de | ||
|
|
f0a4efb242 | ||
|
|
342c3ea565 | ||
|
|
522c6c17e4 | ||
|
|
d8cd8ae36a | ||
|
|
62d5248b0c | ||
|
|
ff597672db | ||
|
|
aa39cefcf1 | ||
|
|
033e8e7573 | ||
|
|
aa78071543 | ||
|
|
48aeeff818 | ||
|
|
864c37f17c | ||
|
|
c7405aebe5 | ||
|
|
9ff2d53466 | ||
|
|
6ba49df485 | ||
|
|
6b25ac3665 | ||
|
|
783832fc71 | ||
|
|
815539b3da | ||
|
|
6ba1b75696 | ||
|
|
ce76b9339e | ||
|
|
6ba15e9884 | ||
|
|
f2a6d6b3c5 | ||
|
|
0295603943 | ||
|
|
ce2659fdd7 | ||
|
|
be49e08f4f | ||
|
|
3ee76ea2bc | ||
|
|
d7fbf29cc1 | ||
|
|
d02e37620d | ||
|
|
53bd0d57ad | ||
|
|
b779c98717 | ||
|
|
4ac19bd29d | ||
|
|
8407ea21da |
2
.github/workflows/build-test.yml
vendored
2
.github/workflows/build-test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.15'
|
||||
go-version: '1.15.2'
|
||||
|
||||
- name: Build
|
||||
run: GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X github.com/pterodactyl/wings/system.Version=dev-${GIT_COMMIT:0:7}" -o build/wings_linux_amd64 -v wings.go
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.15'
|
||||
go-version: '1.15.2'
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Pterodactyl Panel Dockerfile
|
||||
# ----------------------------------
|
||||
|
||||
FROM golang:1.14-alpine
|
||||
FROM golang:1.15-alpine
|
||||
COPY . /go/wings/
|
||||
WORKDIR /go/wings/
|
||||
RUN apk add --no-cache upx \
|
||||
@@ -11,4 +11,4 @@ RUN apk add --no-cache upx \
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=0 /go/wings/wings /usr/bin/
|
||||
CMD ["wings","--config", "/var/lib/pterodactyl/config.yml"]
|
||||
CMD ["wings","--config", "/etc/pterodactyl/config.yml"]
|
||||
@@ -1,6 +1,7 @@
|
||||
[](https://pterodactyl.io)
|
||||
|
||||
[](https://pterodactyl.io/discord)
|
||||
[](https://goreportcard.com/report/github.com/pterodactyl/wings)
|
||||
|
||||
# Pterodactyl Wings
|
||||
Wings is Pterodactyl's server control plane, built for the rapidly changing gaming industry and designed to be
|
||||
@@ -16,12 +17,13 @@ I would like to extend my sincere thanks to the following sponsors for helping f
|
||||
|
||||
| Company | About |
|
||||
| ------- | ----- |
|
||||
| [**BloomVPS**](https://bloomvps.com) | BloomVPS offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
|
||||
| [**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. |
|
||||
| [**VersatileNode**](https://versatilenode.com/) | Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers to provide quality yet cheap services with incredible support. |
|
||||
| [**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-SERVER.de**](https://xcore-server.de/) | XCORE-SERVER.de 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 RoyaleHosting’s reliable servers and network. Easy to use, provisioned in a couple of minutes. |
|
||||
|
||||
## Documentation
|
||||
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
|
||||
@@ -32,4 +34,4 @@ I would like to extend my sincere thanks to the following sponsors for helping f
|
||||
## Reporting Issues
|
||||
Please use the [pterodactyl/panel](https://github.com/pterodactyl/panel) repository to report any issues or make
|
||||
feature requests for Wings. In addition, the [security policy](https://github.com/pterodactyl/panel/security/policy) listed
|
||||
within that repository also applies to Wings.
|
||||
within that repository also applies to Wings.
|
||||
|
||||
18
api/api.go
18
api/api.go
@@ -137,14 +137,15 @@ func IsRequestError(err error) bool {
|
||||
}
|
||||
|
||||
type RequestError struct {
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
response *http.Response
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// Returns the error response in a string form that can be more easily consumed.
|
||||
func (re *RequestError) Error() string {
|
||||
return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%s)", re.Code, re.Detail, re.Status)
|
||||
return fmt.Sprintf("Error response from Panel: %s: %s (HTTP/%d)", re.Code, re.Detail, re.response.StatusCode)
|
||||
}
|
||||
|
||||
func (re *RequestError) String() string {
|
||||
@@ -165,9 +166,12 @@ func (r *PanelRequest) Error() *RequestError {
|
||||
bag := RequestErrorBag{}
|
||||
json.Unmarshal(body, &bag)
|
||||
|
||||
if len(bag.Errors) == 0 {
|
||||
return new(RequestError)
|
||||
e := new(RequestError)
|
||||
if len(bag.Errors) > 0 {
|
||||
e = &bag.Errors[0]
|
||||
}
|
||||
|
||||
return &bag.Errors[0]
|
||||
e.response = r.Response
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ func (r *PanelRequest) ValidateSftpCredentials(request SftpAuthRequest) (*SftpAu
|
||||
|
||||
if r.HasError() {
|
||||
if r.HttpResponseCode() >= 400 && r.HttpResponseCode() < 500 {
|
||||
log.WithFields(log.Fields{
|
||||
"subsystem": "sftp",
|
||||
"username": request.User,
|
||||
"ip": request.IP,
|
||||
}).Warn(r.Error().String())
|
||||
|
||||
return nil, new(sftpInvalidCredentialsError)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
)
|
||||
|
||||
const DefaultHastebinUrl = "https://hastebin.com"
|
||||
const DefaultLogLines = 50
|
||||
|
||||
var (
|
||||
diagnosticsArgs struct {
|
||||
@@ -32,6 +34,7 @@ var (
|
||||
IncludeLogs bool
|
||||
ReviewBeforeUpload bool
|
||||
HastebinURL string
|
||||
LogLines int
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,6 +46,7 @@ var diagnosticsCmd = &cobra.Command{
|
||||
|
||||
func init() {
|
||||
diagnosticsCmd.PersistentFlags().StringVar(&diagnosticsArgs.HastebinURL, "hastebin-url", DefaultHastebinUrl, "The url of the hastebin instance to use.")
|
||||
diagnosticsCmd.PersistentFlags().IntVar(&diagnosticsArgs.LogLines, "log-lines", DefaultLogLines, "The number of log lines to include in the report")
|
||||
}
|
||||
|
||||
// diagnosticsCmdRun collects diagnostics about wings, it's configuration and the node.
|
||||
@@ -96,7 +100,8 @@ func diagnosticsCmdRun(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
printHeader(output, "Wings Configuration")
|
||||
if cfg, err := config.ReadConfiguration(config.DefaultLocation); cfg != nil {
|
||||
cfg, err := config.ReadConfiguration(config.DefaultLocation)
|
||||
if cfg != nil {
|
||||
fmt.Fprintln(output, "Panel Location:", redact(cfg.PanelLocation))
|
||||
fmt.Fprintln(output, "Api Host:", redact(cfg.Api.Host))
|
||||
fmt.Fprintln(output, "Api Port:", cfg.Api.Port)
|
||||
@@ -149,7 +154,15 @@ func diagnosticsCmdRun(cmd *cobra.Command, args []string) {
|
||||
|
||||
printHeader(output, "Latest Wings Logs")
|
||||
if diagnosticsArgs.IncludeLogs {
|
||||
fmt.Fprintln(output, "No logs found. Probably because nobody implemented logging to files yet :(")
|
||||
p := "/var/log/pterodactyl/wings.log"
|
||||
if cfg != nil {
|
||||
p = path.Join(cfg.System.LogDirectory, "wings.log")
|
||||
}
|
||||
if c, err := exec.Command("tail", "-n", strconv.Itoa(diagnosticsArgs.LogLines), p).Output(); err != nil {
|
||||
fmt.Fprintln(output, "No logs found or an error occurred.")
|
||||
} else {
|
||||
fmt.Fprintf(output, "%s\n", string(c))
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(output, "Logs redacted.")
|
||||
}
|
||||
|
||||
44
cmd/root.go
44
cmd/root.go
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/NYTimes/logrotate"
|
||||
"github.com/apex/log/handlers/multi"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/gammazero/workerpool"
|
||||
"golang.org/x/crypto/acme"
|
||||
"net/http"
|
||||
@@ -172,6 +173,11 @@ func rootCmdRun(*cobra.Command, []string) {
|
||||
log.WithField("server", s.Id()).Info("loaded configuration for server")
|
||||
}
|
||||
|
||||
states, err := server.CachedServerStates()
|
||||
if err != nil {
|
||||
log.WithField("error", errors.WithStack(err)).Error("failed to retrieve locally cached server states from disk, assuming all servers in offline state")
|
||||
}
|
||||
|
||||
// Create a new workerpool that limits us to 4 servers being bootstrapped at a time
|
||||
// on Wings. This allows us to ensure the environment exists, write configurations,
|
||||
// and reboot processes without causing a slow-down due to sequential booting.
|
||||
@@ -181,25 +187,39 @@ func rootCmdRun(*cobra.Command, []string) {
|
||||
s := serv
|
||||
|
||||
pool.Submit(func() {
|
||||
s.Log().Info("ensuring server environment exists")
|
||||
// Create a server environment if none exists currently. This allows us to recover from Docker
|
||||
// being reinstalled on the host system for example.
|
||||
if err := s.Environment.Create(); err != nil {
|
||||
s.Log().WithField("error", err).Error("failed to process environment")
|
||||
s.Log().Info("configuring server environment and restoring to previous state")
|
||||
|
||||
var st string
|
||||
if state, exists := states[s.Id()]; exists {
|
||||
st = state
|
||||
}
|
||||
|
||||
r, err := s.Environment.IsRunning()
|
||||
if err != nil {
|
||||
// We ignore missing containers because we don't want to actually block booting of wings at this
|
||||
// point. If we didn't do this and you pruned all of the images and then started wings you could
|
||||
// end up waiting a long period of time for all of the images to be re-pulled on Wings boot rather
|
||||
// than when the server itself is started.
|
||||
if err != nil && !client.IsErrNotFound(err) {
|
||||
s.Log().WithField("error", err).Error("error checking server environment status")
|
||||
}
|
||||
|
||||
// If the server is currently running on Docker, mark the process as being in that state.
|
||||
// We never want to stop an instance that is currently running external from Wings since
|
||||
// that is a good way of keeping things running even if Wings gets in a very corrupted state.
|
||||
// Check if the server was previously running. If so, attempt to start the server now so that Wings
|
||||
// can pick up where it left off. If the environment does not exist at all, just create it and then allow
|
||||
// the normal flow to execute.
|
||||
//
|
||||
// This will also validate that a server process is running if the last tracked state we have
|
||||
// is that it was running, but we see that the container process is not currently running.
|
||||
if r || (!r && s.IsRunning()) {
|
||||
// This does mean that booting wings after a catastrophic machine crash and wiping out the Docker images
|
||||
// as a result will result in a slow boot.
|
||||
if !r && (st == environment.ProcessRunningState || st == environment.ProcessStartingState) {
|
||||
if err := s.HandlePowerAction(server.PowerActionStart); err != nil {
|
||||
s.Log().WithField("error", errors.WithStack(err)).Warn("failed to return server to running state")
|
||||
}
|
||||
} else if r || (!r && s.IsRunning()) {
|
||||
// If the server is currently running on Docker, mark the process as being in that state.
|
||||
// We never want to stop an instance that is currently running external from Wings since
|
||||
// that is a good way of keeping things running even if Wings gets in a very corrupted state.
|
||||
//
|
||||
// This will also validate that a server process is running if the last tracked state we have
|
||||
// is that it was running, but we see that the container process is not currently running.
|
||||
s.Log().Info("detected server is running, re-attaching to process...")
|
||||
|
||||
s.SetState(environment.ProcessRunningState)
|
||||
|
||||
@@ -63,7 +63,7 @@ type Configuration struct {
|
||||
|
||||
// AllowedMounts is a list of allowed host-system mount points.
|
||||
// This is required to have the "Server Mounts" feature work properly.
|
||||
AllowedMounts []string `json:"allowed_mounts" yaml:"allowed_mounts"`
|
||||
AllowedMounts []string `json:"-" yaml:"allowed_mounts"`
|
||||
|
||||
// AllowedOrigins is a list of allowed request origins.
|
||||
// The Panel URL is automatically allowed, this is only needed for adding
|
||||
|
||||
@@ -135,7 +135,21 @@ func (sc *SystemConfiguration) EnableLogRotation() error {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
t, err := template.ParseFiles("templates/logrotate.tpl")
|
||||
t, err := template.New("logrotate").Parse(`
|
||||
{{.LogDirectory}}/wings.log {
|
||||
size 10M
|
||||
compress
|
||||
delaycompress
|
||||
dateext
|
||||
maxage 7
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 {{.User.Uid}} {{.User.Gid}}
|
||||
postrotate
|
||||
killall -SIGHUP wings
|
||||
endscript
|
||||
}`)
|
||||
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
@@ -4,20 +4,24 @@ type ConsoleThrottles struct {
|
||||
// Whether or not the throttler is enabled for this instance.
|
||||
Enabled bool `json:"enabled" yaml:"enabled" default:"true"`
|
||||
|
||||
// The total number of throttle activations that must accumulate before a server is
|
||||
// forcibly stopped for violating these limits.
|
||||
KillAtCount uint64 `json:"kill_at_count" yaml:"kill_at_count" default:"5"`
|
||||
|
||||
// The amount of time in milliseconds that a server process must go through without
|
||||
// triggering an output warning before the throttle activation count begins decreasing.
|
||||
// This time is measured in milliseconds.
|
||||
Decay uint64 `json:"decay" yaml:"decay" default:"10000"`
|
||||
|
||||
// The total number of lines that can be output in a given CheckInterval period before
|
||||
// The total number of lines that can be output in a given LineResetInterval period before
|
||||
// a warning is triggered and counted against the server.
|
||||
Lines uint64 `json:"lines" yaml:"lines" default:"1000"`
|
||||
Lines uint64 `json:"lines" yaml:"lines" default:"2000"`
|
||||
|
||||
// The amount of time that must pass between intervals before the count is reset. This
|
||||
// value is in milliseconds.
|
||||
CheckInterval uint64 `json:"check_interval" yaml:"check_interval" default:"100"`
|
||||
// The total number of throttle activations that can accumulate before a server is considered
|
||||
// to be breaching and will be stopped. This value is decremented by one every DecayInterval.
|
||||
MaximumTriggerCount uint64 `json:"maximum_trigger_count" yaml:"maximum_trigger_count" default:"5"`
|
||||
|
||||
// The amount of time after which the number of lines processed is reset to 0. This runs in
|
||||
// a constant loop and is not affected by the current console output volumes. By default, this
|
||||
// will reset the processed line count back to 0 every 100ms.
|
||||
LineResetInterval uint64 `json:"line_reset_interval" yaml:"line_reset_interval" default:"100"`
|
||||
|
||||
// The amount of time in milliseconds that must pass without an output warning being triggered
|
||||
// before a throttle activation is decremented.
|
||||
DecayInterval uint64 `json:"decay_interval" yaml:"decay_interval" default:"10000"`
|
||||
|
||||
// The amount of time that a server is allowed to be stopping for before it is terminated
|
||||
// forfully if it triggers output throttles.
|
||||
StopGracePeriod uint `json:"stop_grace_period" yaml:"stop_grace_period" default:"15"`
|
||||
}
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
version: '3'
|
||||
version: '3.5'
|
||||
services:
|
||||
daemon:
|
||||
build: .
|
||||
restart: always
|
||||
hostname: daemon
|
||||
networks:
|
||||
- daemon0
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "2022:2022"
|
||||
tty: true
|
||||
environment:
|
||||
- "DEBUG=false"
|
||||
- "TZ=UTC" # change to the three letter timezone of your choosing
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
- "/var/lib/docker/containers/:/var/lib/docker/containers/"
|
||||
- "/etc/pterodactyl/:/etc/pterodactyl/"
|
||||
- "/var/lib/pterodactyl/:/var/lib/pterodactyl/"
|
||||
- "/srv/daemon-data/:/srv/daemon-data/"
|
||||
- "/var/log/pterodactyl/:/var/log/pterodactyl/"
|
||||
- "/tmp/pterodactyl/:/tmp/pterodactyl/"
|
||||
- "/etc/timezone:/etc/timezone:ro"
|
||||
## you may need /srv/daemon-data if you are upgrading from an old daemon
|
||||
## - "/srv/daemon-data/:/srv/daemon-data/"
|
||||
## Required for ssl if you user let's encrypt. uncomment to use.
|
||||
## - "/etc/letsencrypt/:/etc/letsencrypt/"
|
||||
|
||||
networks:
|
||||
default:
|
||||
daemon0:
|
||||
name: daemon0
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
- subnet: "172.21.0.0/16"
|
||||
driver_opts:
|
||||
com.docker.network.bridge.name: daemon0
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Configures the required network for the docker environment.
|
||||
func ConfigureDocker(c *config.DockerConfiguration) error {
|
||||
// Ensure the required docker network exists on the system.
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/apex/log"
|
||||
"github.com/docker/docker/api/types"
|
||||
@@ -19,6 +20,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type imagePullStatus struct {
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -148,7 +154,7 @@ func (e *Environment) Create() error {
|
||||
// Convert 127.0.0.1 to the pterodactyl0 network interface if the environment is Docker
|
||||
// so that the server operates as expected.
|
||||
if v == "SERVER_IP=127.0.0.1" {
|
||||
evs[i] = "SERVER_IP="+config.Get().Docker.Network.Interface
|
||||
evs[i] = "SERVER_IP=" + config.Get().Docker.Network.Interface
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +313,9 @@ func (e *Environment) followOutput() error {
|
||||
//
|
||||
// TODO: local images
|
||||
func (e *Environment) ensureImageExists(image string) error {
|
||||
e.Events().Publish(environment.DockerImagePullStarted, "")
|
||||
defer e.Events().Publish(environment.DockerImagePullCompleted, "")
|
||||
|
||||
// Give it up to 15 minutes to pull the image. I think this should cover 99.8% of cases where an
|
||||
// image pull might fail. I can't imagine it will ever take more than 15 minutes to fully pull
|
||||
// an image. Let me know when I am inevitably wrong here...
|
||||
@@ -355,7 +364,7 @@ func (e *Environment) ensureImageExists(image string) error {
|
||||
log.WithFields(log.Fields{
|
||||
"image": image,
|
||||
"container_id": e.Id,
|
||||
"error": errors.New(err.Error()),
|
||||
"err": err.Error(),
|
||||
}).Warn("unable to pull requested image from remote source, however the image exists locally")
|
||||
|
||||
// Okay, we found a matching container image, in that case just go ahead and return
|
||||
@@ -374,12 +383,18 @@ func (e *Environment) ensureImageExists(image string) error {
|
||||
// is done being pulled, which is what we need.
|
||||
scanner := bufio.NewScanner(out)
|
||||
for scanner.Scan() {
|
||||
continue
|
||||
s := imagePullStatus{}
|
||||
fmt.Println(scanner.Text())
|
||||
if err := json.Unmarshal(scanner.Bytes(), &s); err == nil {
|
||||
e.Events().Publish(environment.DockerImagePullStatus, s.Status+" "+s.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithField("image", image).Debug("completed docker image pull")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ type Environment struct {
|
||||
// reference the container from here on out. This should be unique per-server (we use the UUID
|
||||
// by default). The container does not need to exist at this point.
|
||||
func New(id string, m *Metadata, c *environment.Configuration) (*Environment, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,3 +176,9 @@ func (e *Environment) SetStopConfiguration(c *api.ProcessStopConfiguration) {
|
||||
e.meta.Stop = c
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Environment) SetImage(i string) {
|
||||
e.mu.Lock()
|
||||
e.meta.Image = i
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -183,13 +183,21 @@ func (e *Environment) WaitForStop(seconds uint, terminate bool) error {
|
||||
case <-ctx.Done():
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
if terminate {
|
||||
return e.Terminate(os.Kill)
|
||||
log.WithField("container_id", e.Id).Debug("server did not stop in time, executing process termination")
|
||||
|
||||
return errors.WithStack(e.Terminate(os.Kill))
|
||||
}
|
||||
|
||||
return errors.WithStack(ctxErr)
|
||||
}
|
||||
case err := <-errChan:
|
||||
if err != nil {
|
||||
if terminate {
|
||||
log.WithField("container_id", e.Id).WithField("error", errors.WithStack(err)).Warn("error while waiting for container stop, attempting process termination")
|
||||
|
||||
return errors.WithStack(e.Terminate(os.Kill))
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
case <-ok:
|
||||
|
||||
@@ -15,6 +15,8 @@ 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()
|
||||
e.stream = s
|
||||
@@ -24,13 +26,13 @@ func (e *Environment) setStream(s *types.HijackedResponse) {
|
||||
// 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 !e.IsAttached() {
|
||||
return errors.New("attempting to send command to non-attached instance")
|
||||
}
|
||||
|
||||
if e.meta.Stop != nil {
|
||||
// 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
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ConsoleOutputEvent = "console output"
|
||||
StateChangeEvent = "state change"
|
||||
ResourceEvent = "resources"
|
||||
ConsoleOutputEvent = "console output"
|
||||
StateChangeEvent = "state change"
|
||||
ResourceEvent = "resources"
|
||||
DockerImagePullStarted = "docker image pull started"
|
||||
DockerImagePullStatus = "docker image pull status"
|
||||
DockerImagePullCompleted = "docker image pull completed"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
109
events/events.go
109
events/events.go
@@ -2,6 +2,8 @@ package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gammazero/workerpool"
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -12,14 +14,13 @@ type Event struct {
|
||||
}
|
||||
|
||||
type EventBus struct {
|
||||
sync.RWMutex
|
||||
|
||||
subscribers map[string]map[chan Event]struct{}
|
||||
mu sync.RWMutex
|
||||
pools map[string]*CallbackPool
|
||||
}
|
||||
|
||||
func New() *EventBus {
|
||||
return &EventBus{
|
||||
subscribers: make(map[string]map[chan Event]struct{}),
|
||||
pools: make(map[string]*CallbackPool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,29 +40,36 @@ func (e *EventBus) Publish(topic string, data string) {
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
// Acquire a read lock and loop over all of the channels registered for the topic. This
|
||||
// avoids a panic crash if the process tries to unregister the channel while this routine
|
||||
// is running.
|
||||
go func() {
|
||||
e.RLock()
|
||||
defer e.RUnlock()
|
||||
|
||||
if ch, ok := e.subscribers[t]; ok {
|
||||
e := Event{Data: data, Topic: topic}
|
||||
|
||||
for channel := range ch {
|
||||
go func(channel chan Event, e Event) {
|
||||
channel <- e
|
||||
}(channel, e)
|
||||
}
|
||||
if cp, ok := e.pools[t]; ok {
|
||||
for _, callback := range cp.callbacks {
|
||||
c := *callback
|
||||
evt := Event{Data: data, Topic: topic}
|
||||
// Using the workerpool with one worker allows us to execute events in a FIFO manner. Running
|
||||
// this using goroutines would cause things such as console output to just output in random order
|
||||
// if more than one event is fired at the same time.
|
||||
//
|
||||
// However, the pool submission does not block the execution of this function itself, allowing
|
||||
// us to call publish without blocking any of the other pathways.
|
||||
//
|
||||
// @see https://github.com/pterodactyl/panel/issues/2303
|
||||
cp.pool.Submit(func() {
|
||||
c(evt)
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Publishes a JSON message to a given topic.
|
||||
func (e *EventBus) PublishJson(topic string, data interface{}) error {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
e.Publish(topic, string(b))
|
||||
@@ -69,45 +77,46 @@ func (e *EventBus) PublishJson(topic string, data interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribe to an emitter topic using a channel.
|
||||
func (e *EventBus) Subscribe(topics []string, ch chan Event) {
|
||||
e.Lock()
|
||||
defer e.Unlock()
|
||||
// Register a callback function that will be executed each time one of the events using the topic
|
||||
// name is called.
|
||||
func (e *EventBus) On(topic string, callback *func(Event)) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for _, topic := range topics {
|
||||
if _, exists := e.subscribers[topic]; !exists {
|
||||
e.subscribers[topic] = make(map[chan Event]struct{})
|
||||
// Check if this topic has been registered at least once for the event listener, and if
|
||||
// not create an empty struct for the topic.
|
||||
if _, exists := e.pools[topic]; !exists {
|
||||
e.pools[topic] = &CallbackPool{
|
||||
callbacks: make([]*func(Event), 0),
|
||||
pool: workerpool.New(1),
|
||||
}
|
||||
}
|
||||
|
||||
// Only set the channel if there is not currently a matching one for this topic. This
|
||||
// avoids registering two identical listeners for the same topic and causing pain in
|
||||
// the unsubscribe functionality as well.
|
||||
if _, exists := e.subscribers[topic][ch]; !exists {
|
||||
e.subscribers[topic][ch] = struct{}{}
|
||||
}
|
||||
// If this callback is not already registered as an event listener, go ahead and append
|
||||
// it to the array of callbacks for this topic.
|
||||
e.pools[topic].Add(callback)
|
||||
}
|
||||
|
||||
// Removes an event listener from the bus.
|
||||
func (e *EventBus) Off(topic string, callback *func(Event)) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if cp, ok := e.pools[topic]; ok {
|
||||
cp.Remove(callback)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe a channel from a given topic.
|
||||
func (e *EventBus) Unsubscribe(topics []string, ch chan Event) {
|
||||
e.Lock()
|
||||
defer e.Unlock()
|
||||
// Removes all of the event listeners that have been registered for any topic. Also stops the worker
|
||||
// pool to close that routine.
|
||||
func (e *EventBus) Destroy() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for _, topic := range topics {
|
||||
if _, exists := e.subscribers[topic][ch]; exists {
|
||||
delete(e.subscribers[topic], ch)
|
||||
}
|
||||
// Stop every pool that exists for a given callback topic.
|
||||
for _, cp := range e.pools {
|
||||
cp.pool.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Removes all of the event listeners for the server. This is used when a server
|
||||
// is being deleted to avoid a bunch of de-reference errors cropping up. Obviously
|
||||
// should also check elsewhere and handle a server reference going nil, but this
|
||||
// won't hurt.
|
||||
func (e *EventBus) UnsubscribeAll() {
|
||||
e.Lock()
|
||||
defer e.Unlock()
|
||||
|
||||
// Reset the entire struct into an empty map.
|
||||
e.subscribers = make(map[string]map[chan Event]struct{})
|
||||
e.pools = make(map[string]*CallbackPool)
|
||||
}
|
||||
|
||||
49
events/pool.go
Normal file
49
events/pool.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/gammazero/workerpool"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type CallbackPool struct {
|
||||
callbacks []*func(Event)
|
||||
pool *workerpool.WorkerPool
|
||||
}
|
||||
|
||||
// Pushes a new callback into the array of listeners for the pool.
|
||||
func (cp *CallbackPool) Add(callback *func(Event)) {
|
||||
if cp.index(reflect.ValueOf(callback)) < 0 {
|
||||
cp.callbacks = append(cp.callbacks, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// Removes a callback from the array of registered callbacks if it exists.
|
||||
func (cp *CallbackPool) Remove(callback *func(Event)) {
|
||||
i := cp.index(reflect.ValueOf(callback))
|
||||
|
||||
// If i < 0 it means there was no index found for the given callback, meaning it was
|
||||
// never registered or was already unregistered from the listeners. Also double check
|
||||
// that we didn't somehow escape the length of the topic callback (not sure how that
|
||||
// would happen, but lets avoid a panic condition).
|
||||
if i < 0 || i >= len(cp.callbacks) {
|
||||
return
|
||||
}
|
||||
|
||||
// We can assume that the topic still exists at this point since we acquire an exclusive
|
||||
// lock on the process, and the "e.index" function cannot return a value >= 0 if there is
|
||||
// no topic already existing.
|
||||
cp.callbacks = append(cp.callbacks[:i], cp.callbacks[i+1:]...)
|
||||
}
|
||||
|
||||
// Finds the index of a given callback in the topic by comparing all of the registered callback
|
||||
// pointers to the passed function. This function does not aquire a lock as it should only be called
|
||||
// within the confines of a function that has already acquired a lock for the duration of the lookup.
|
||||
func (cp *CallbackPool) index(v reflect.Value) int {
|
||||
for i, handler := range cp.callbacks {
|
||||
if reflect.ValueOf(handler).Pointer() == v.Pointer() {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -24,6 +24,7 @@ require (
|
||||
github.com/docker/go-metrics v0.0.1 // indirect
|
||||
github.com/docker/go-units v0.4.0 // indirect
|
||||
github.com/fatih/color v1.9.0
|
||||
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd
|
||||
github.com/frankban/quicktest v1.10.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.1.1
|
||||
|
||||
2
go.sum
2
go.sum
@@ -123,6 +123,8 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
|
||||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=
|
||||
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd h1:b/30UOB56Rhfe185ZfgvZT0/HOql0OzxuiNOxRKXRXc=
|
||||
github.com/franela/goblin v0.0.0-20200825194134-80c0062ed6cd/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo=
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/frankban/quicktest v1.10.2 h1:19ARM85nVi4xH7xPXuc5eM/udya5ieh7b/Sv+d844Tk=
|
||||
github.com/frankban/quicktest v1.10.2/go.mod h1:K+q6oSqb0W0Ininfk863uOk1lMy69l/P6txr3mVT54s=
|
||||
|
||||
@@ -2,16 +2,12 @@ package installer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/apex/log"
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/buger/jsonparser"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/api"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type Installer struct {
|
||||
@@ -95,33 +91,6 @@ func (i *Installer) Server() *server.Server {
|
||||
return i.server
|
||||
}
|
||||
|
||||
// Executes the installer process, creating the server and running through the
|
||||
// associated installation process based on the parameters passed through for
|
||||
// the server instance.
|
||||
func (i *Installer) Execute() {
|
||||
p := path.Join(config.Get().System.Data, i.Uuid())
|
||||
l := log.WithFields(log.Fields{"server": i.Uuid(), "process": "installer"})
|
||||
|
||||
l.WithField("path", p).Debug("creating required server data directory")
|
||||
if err := os.MkdirAll(p, 0755); err != nil {
|
||||
l.WithFields(log.Fields{"path": p, "error": errors.WithStack(err)}).Error("failed to create server data directory")
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Chown(p, config.Get().System.User.Uid, config.Get().System.User.Gid); err != nil {
|
||||
l.WithField("error", errors.WithStack(err)).Error("failed to chown server data directory")
|
||||
return
|
||||
}
|
||||
|
||||
l.Debug("creating required environment for server instance")
|
||||
if err := i.server.Environment.Create(); err != nil {
|
||||
l.WithField("error", err).Error("failed to create environment for server")
|
||||
return
|
||||
}
|
||||
|
||||
l.Info("successfully created environment for server during install process")
|
||||
}
|
||||
|
||||
// Returns a string value from the JSON data provided.
|
||||
func getString(data []byte, key ...string) string {
|
||||
value, _ := jsonparser.GetString(data, key...)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -169,8 +170,13 @@ func (f *ConfigurationFile) Parse(path string, internal bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Create(path); err != nil {
|
||||
return errors.WithStack(err)
|
||||
b := strings.TrimSuffix(path, filepath.Base(path))
|
||||
if err := os.MkdirAll(b, 0755); err != nil {
|
||||
return errors.Wrap(err, "failed to create base directory for missing configuration file")
|
||||
} else {
|
||||
if _, err := os.Create(path); err != nil {
|
||||
return errors.Wrap(err, "failed to create missing configuration file")
|
||||
}
|
||||
}
|
||||
|
||||
return f.Parse(path, true)
|
||||
@@ -409,7 +415,7 @@ func (f *ConfigurationFile) parseTextFile(path string) error {
|
||||
// If there was a replacement that occurred on this specific line, do a write to the file
|
||||
// immediately to write that modified content to the disk.
|
||||
if hasReplaced {
|
||||
if _, err := file.WriteAt([]byte(t), int64(len(scanner.Bytes()))); err != nil {
|
||||
if _, err := file.WriteAt([]byte(t+"\n"), int64(len(scanner.Bytes()))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -437,8 +443,7 @@ func (f *ConfigurationFile) parsePropertiesFile(path string) error {
|
||||
scanner := bufio.NewScanner(f2)
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
|
||||
if text[0] != '#' {
|
||||
if len(text) > 0 && text[0] != '#' {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
@@ -33,7 +35,7 @@ func TrackedError(err error) *RequestError {
|
||||
// generated this server for the purposes of logging.
|
||||
func TrackedServerError(err error, s *server.Server) *RequestError {
|
||||
return &RequestError{
|
||||
Err: errors.WithStack(err),
|
||||
Err: err,
|
||||
Uuid: uuid.Must(uuid.NewRandom()).String(),
|
||||
Message: "",
|
||||
server: s,
|
||||
@@ -95,6 +97,46 @@ func (e *RequestError) AbortWithServerError(c *gin.Context) {
|
||||
e.AbortWithStatus(http.StatusInternalServerError, c)
|
||||
}
|
||||
|
||||
// Handle specific filesystem errors for a server.
|
||||
func (e *RequestError) AbortFilesystemError(c *gin.Context) {
|
||||
if errors.Is(e.Err, os.ErrNotExist) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(e.Err, filesystem.ErrNotEnoughDiskSpace) {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "There is not enough disk space available to perform that action.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(e.Err.Error(), "file name too long") {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "File name is too long.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if e, ok := e.Err.(*os.SyscallError); ok && e.Syscall == "readdirent" {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested directory does not exist.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(e.Err.Error(), "file name too long") {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cannot perform that action: file name is too long.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
e.AbortWithServerError(c)
|
||||
}
|
||||
|
||||
// Format the error to a string and include the UUID.
|
||||
func (e *RequestError) Error() string {
|
||||
return fmt.Sprintf("%v (uuid: %s)", e.Err, e.Uuid)
|
||||
|
||||
@@ -70,7 +70,7 @@ func getDownloadFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
p, _ := s.Filesystem.SafePath(token.FilePath)
|
||||
p, _ := s.Filesystem().SafePath(token.FilePath)
|
||||
st, err := os.Stat(p)
|
||||
// If there is an error or we're somehow trying to download a directory, just
|
||||
// respond with the appropriate error.
|
||||
|
||||
@@ -169,9 +169,16 @@ func postServerInstall(c *gin.Context) {
|
||||
func postServerReinstall(c *gin.Context) {
|
||||
s := GetServer(c.Param("server"))
|
||||
|
||||
go func(serv *server.Server) {
|
||||
if err := serv.Reinstall(); err != nil {
|
||||
serv.Log().WithField("error", err).Error("failed to complete server re-install process")
|
||||
if s.ExecutingPowerAction() {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "Cannot execute server reinstall event while another power action is running.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
go func(s *server.Server) {
|
||||
if err := s.Reinstall(); err != nil {
|
||||
s.Log().WithField("error", err).Error("failed to complete server re-install process")
|
||||
}
|
||||
}(s)
|
||||
|
||||
@@ -198,7 +205,9 @@ func deleteServer(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Unsubscribe all of the event listeners.
|
||||
s.Events().UnsubscribeAll()
|
||||
s.Events().Destroy()
|
||||
s.Throttler().StopTimer()
|
||||
s.Websockets().CancelAll()
|
||||
|
||||
// Destroy the environment; in Docker this will handle a running container and
|
||||
// forcibly terminate it before removing the container, so we do not need to handle
|
||||
@@ -220,7 +229,7 @@ func deleteServer(c *gin.Context) {
|
||||
"error": errors.WithStack(err),
|
||||
}).Warn("failed to remove server files during deletion process")
|
||||
}
|
||||
}(s.Filesystem.Path())
|
||||
}(s.Filesystem().Path())
|
||||
|
||||
var uuid = s.Id()
|
||||
server.GetServers().Remove(func(s2 *server.Server) bool {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/router/tokens"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -29,34 +29,12 @@ func getServerFileContents(c *gin.Context) {
|
||||
}
|
||||
p = "/" + strings.TrimLeft(p, "/")
|
||||
|
||||
cleaned, err := s.Filesystem.SafePath(p)
|
||||
st, err := s.Filesystem().Stat(p)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The file requested could not be found.",
|
||||
})
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
st, err := s.Filesystem.Stat(cleaned)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
|
||||
if st.Info.IsDir() {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested resource was not found on the system.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
c.Header("X-Mime-Type", st.Mimetype)
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||
|
||||
@@ -67,7 +45,10 @@ func getServerFileContents(c *gin.Context) {
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
}
|
||||
|
||||
bufio.NewReader(f).WriteTo(c.Writer)
|
||||
if err := s.Filesystem().Readfile(p, c.Writer); err != nil {
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the contents of a directory for a server.
|
||||
@@ -80,16 +61,9 @@ func getServerListDirectory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := s.Filesystem.ListDirectory(d)
|
||||
stats, err := s.Filesystem().ListDirectory(d)
|
||||
if err != nil {
|
||||
if err.Error() == "readdirent: not a directory" {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested directory does not exist.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,7 +107,7 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
if err := s.Filesystem.Rename(pf, pt); err != nil {
|
||||
if err := s.Filesystem().Rename(pf, pt); err != nil {
|
||||
// Return nil if the error is an is not exists.
|
||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -156,7 +130,7 @@ func putServerRenameFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -175,15 +149,8 @@ func postServerCopyFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem.Copy(data.Location); err != nil {
|
||||
// Check if the file does not exist.
|
||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
if err := s.Filesystem().Copy(data.Location); err != nil {
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +189,7 @@ func postServerDeleteFiles(c *gin.Context) {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return s.Filesystem.Delete(pi)
|
||||
return s.Filesystem().Delete(pi)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -246,8 +213,15 @@ func postServerWriteFile(c *gin.Context) {
|
||||
}
|
||||
f = "/" + strings.TrimLeft(f, "/")
|
||||
|
||||
if err := s.Filesystem.Writefile(f, c.Request.Body); err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
if err := s.Filesystem().Writefile(f, c.Request.Body); err != nil {
|
||||
if errors.Is(err, filesystem.ErrIsDirectory) {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Cannot write file, name conflicts with an existing directory by the same name.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,7 +241,14 @@ func postServerCreateDirectory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem.CreateDirectory(data.Name, data.Path); err != nil {
|
||||
if err := s.Filesystem().CreateDirectory(data.Name, data.Path); err != nil {
|
||||
if err.Error() == "not a directory" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Part of the path being created is not a directory (ENOTDIR).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
@@ -294,20 +275,20 @@ func postServerCompressFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.Filesystem.HasSpaceAvailable(true) {
|
||||
if !s.Filesystem().HasSpaceAvailable(true) {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "This server does not have enough available disk space to generate a compressed archive.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
f, err := s.Filesystem.CompressFiles(data.RootPath, data.Files)
|
||||
f, err := s.Filesystem().CompressFiles(data.RootPath, data.Files)
|
||||
if err != nil {
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &server.Stat{
|
||||
c.JSON(http.StatusOK, &filesystem.Stat{
|
||||
Info: f,
|
||||
Mimetype: "application/tar+gzip",
|
||||
})
|
||||
@@ -325,8 +306,18 @@ func postServerDecompressFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSpace, err := s.Filesystem.SpaceAvailableForDecompression(data.RootPath, data.File)
|
||||
hasSpace, err := s.Filesystem().SpaceAvailableForDecompression(data.RootPath, data.File)
|
||||
if err != nil {
|
||||
// Handle an unknown format error.
|
||||
if errors.Is(err, filesystem.ErrUnknownArchiveFormat) {
|
||||
s.Log().WithField("error", err).Warn("failed to decompress file due to unknown format")
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unknown archive format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
return
|
||||
}
|
||||
@@ -338,15 +329,27 @@ func postServerDecompressFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Filesystem.DecompressFile(data.RootPath, data.File); err != nil {
|
||||
// Check if the file does not exist.
|
||||
// NOTE: os.IsNotExist() does not work if the error is wrapped.
|
||||
if err := s.Filesystem().DecompressFile(data.RootPath, data.File); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.Status(http.StatusNotFound)
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": "The requested archive was not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortWithServerError(c)
|
||||
// If the file is busy for some reason just return a nicer error to the user since there is not
|
||||
// much we specifically can do. They'll need to stop the running server process in order to overwrite
|
||||
// a file like this.
|
||||
if strings.Contains(err.Error(), "text file busy") {
|
||||
s.Log().WithField("error", err).Warn("failed to decompress file due to busy text file")
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
"error": "One or more files this archive is attempting to overwrite are currently in use by another process. Please try again.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
TrackedServerError(err, s).AbortFilesystemError(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -368,13 +371,6 @@ func postServerUploadFiles(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.Filesystem.HasSpaceAvailable(true) {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "This server does not have enough available disk space to accept any file uploads.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
|
||||
@@ -393,8 +389,13 @@ func postServerUploadFiles(c *gin.Context) {
|
||||
|
||||
directory := c.Query("directory")
|
||||
|
||||
var totalSize int64
|
||||
for _, header := range headers {
|
||||
p, err := s.Filesystem.SafePath(filepath.Join(directory, header.Filename))
|
||||
totalSize += header.Size
|
||||
}
|
||||
|
||||
for _, header := range headers {
|
||||
p, err := s.Filesystem().SafePath(filepath.Join(directory, header.Filename))
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
@@ -416,7 +417,7 @@ func handleFileUpload(p string, s *server.Server, header *multipart.FileHeader)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := s.Filesystem.Writefile(p, file); err != nil {
|
||||
if err := s.Filesystem().Writefile(p, file); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
"github.com/pterodactyl/wings/router/websocket"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Upgrades a connection to a websocket and passes events along between.
|
||||
@@ -23,6 +24,28 @@ func getServerWebsocket(c *gin.Context) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Track this open connection on the server so that we can close them all programtically
|
||||
// if the server is deleted.
|
||||
s.Websockets().Push(handler.Uuid(), &cancel)
|
||||
defer s.Websockets().Remove(handler.Uuid())
|
||||
|
||||
// Listen for the context being canceled and then close the websocket connection. This normally
|
||||
// just happens because you're disconnecting from the socket in the browser, however in some
|
||||
// cases we close the connections programatically (e.g. deleting the server) and need to send
|
||||
// a close message to the websocket so it disconnects.
|
||||
go func(ctx context.Context, c *ws.Conn) {
|
||||
ListenerLoop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
handler.Connection.WriteControl(ws.CloseMessage, ws.FormatCloseMessage(ws.CloseGoingAway, "server deleted"), time.Now().Add(time.Second*5))
|
||||
// A break right here without defining the specific loop would only break the select
|
||||
// and not actually break the for loop, thus causing this routine to stick around forever.
|
||||
break ListenerLoop
|
||||
}
|
||||
}
|
||||
}(ctx, handler.Connection)
|
||||
|
||||
go handler.ListenForServerEvents(ctx)
|
||||
go handler.ListenForExpiration(ctx)
|
||||
|
||||
|
||||
@@ -57,7 +57,11 @@ func postCreateServer(c *gin.Context) {
|
||||
// cycle. If there are any errors they will be logged and communicated back
|
||||
// to the Panel where a reinstall may take place.
|
||||
go func(i *installer.Installer) {
|
||||
i.Execute()
|
||||
err := i.Server().CreateEnvironment()
|
||||
if err != nil {
|
||||
i.Server().Log().WithField("error", err).Error("failed to create server environment during install process")
|
||||
return
|
||||
}
|
||||
|
||||
if err := i.Server().Install(false); err != nil {
|
||||
log.WithFields(log.Fields{"server": i.Uuid(), "error": err}).Error("failed to run install process for server")
|
||||
|
||||
@@ -67,7 +67,7 @@ func getServerArchive(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(s.Archiver.ArchivePath())
|
||||
file, err := os.Open(s.Archiver.Path())
|
||||
if err != nil {
|
||||
tserr := TrackedServerError(err, s)
|
||||
if !os.IsNotExist(err) {
|
||||
@@ -84,7 +84,7 @@ func getServerArchive(c *gin.Context) {
|
||||
c.Header("X-Checksum", checksum)
|
||||
c.Header("X-Mime-Type", st.Mimetype)
|
||||
c.Header("Content-Length", strconv.Itoa(int(st.Info.Size())))
|
||||
c.Header("Content-Disposition", "attachment; filename="+s.Archiver.ArchiveName())
|
||||
c.Header("Content-Disposition", "attachment; filename="+s.Archiver.Name())
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
|
||||
bufio.NewReader(file).WriteTo(c.Writer)
|
||||
@@ -277,10 +277,13 @@ func postTransfer(c *gin.Context) {
|
||||
server.GetServers().Add(i.Server())
|
||||
|
||||
// Create the server's environment (note this does not execute the install script)
|
||||
i.Execute()
|
||||
if err := i.Server().CreateEnvironment(); err != nil {
|
||||
l.WithField("error", err).Error("failed to create server environment")
|
||||
return
|
||||
}
|
||||
|
||||
// Un-archive the archive. That sounds weird..
|
||||
if err := archiver.NewTarGz().Unarchive(archivePath, i.Server().Filesystem.Path()); err != nil {
|
||||
if err := archiver.NewTarGz().Unarchive(archivePath, i.Server().Filesystem().Path()); err != nil {
|
||||
l.WithField("error", errors.WithStack(err)).Error("failed to extract server archive")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,22 +51,25 @@ var e = []string{
|
||||
// to the connected websocket.
|
||||
func (h *Handler) ListenForServerEvents(ctx context.Context) {
|
||||
h.server.Log().Debug("listening for server events over websocket")
|
||||
callback := func(e events.Event) {
|
||||
if err := h.SendJson(&Message{Event: e.Topic, Args: []string{e.Data}}); err != nil {
|
||||
h.server.Log().WithField("error", err).Warn("error while sending server data over websocket")
|
||||
}
|
||||
}
|
||||
|
||||
eventChannel := make(chan events.Event)
|
||||
h.server.Events().Subscribe(e, eventChannel)
|
||||
// Subscribe to all of the events with the same callback that will push the data out over the
|
||||
// websocket for the server.
|
||||
for _, evt := range e {
|
||||
h.server.Events().On(evt, &callback)
|
||||
}
|
||||
|
||||
go func(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
h.server.Events().Unsubscribe(e, eventChannel)
|
||||
|
||||
close(eventChannel)
|
||||
// Once this context is stopped, de-register all of the listeners that have been registered.
|
||||
for _, evt := range e {
|
||||
h.server.Events().Off(evt, &callback)
|
||||
}
|
||||
}
|
||||
}(ctx)
|
||||
|
||||
for d := range eventChannel {
|
||||
if err := h.SendJson(&Message{Event: d.Topic, Args: []string{d.Data}}); err != nil {
|
||||
h.server.Log().WithField("error", err).Warn("error while sending server data over websocket")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,13 @@ const (
|
||||
SetStateEvent = "set state"
|
||||
SendServerLogsEvent = "send logs"
|
||||
SendCommandEvent = "send command"
|
||||
SendStatsEvent = "send stats"
|
||||
ErrorEvent = "daemon error"
|
||||
JwtErrorEvent = "jwt error"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
// The event to perform. Should be one of the following that are supported:
|
||||
//
|
||||
// - status : Returns the server's power state.
|
||||
// - logs : Returns the server log data at the time of the request.
|
||||
// - power : Performs a power action against the server based the data.
|
||||
// - command : Performs a command on a server using the data field.
|
||||
// The event to perform.
|
||||
Event string `json:"event"`
|
||||
|
||||
// The data to pass along, only used by power/command currently. Other requests
|
||||
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/environment/docker"
|
||||
"github.com/pterodactyl/wings/router/tokens"
|
||||
"github.com/pterodactyl/wings/server"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,9 +34,24 @@ const (
|
||||
|
||||
type Handler struct {
|
||||
sync.RWMutex
|
||||
|
||||
Connection *websocket.Conn
|
||||
jwt *tokens.WebsocketPayload `json:"-"`
|
||||
server *server.Server
|
||||
uuid uuid.UUID
|
||||
}
|
||||
|
||||
var (
|
||||
ErrJwtNotPresent = errors.New("jwt: no jwt present")
|
||||
ErrJwtNoConnectPerm = errors.New("jwt: missing connect permission")
|
||||
ErrJwtUuidMismatch = errors.New("jwt: server uuid mismatch")
|
||||
)
|
||||
|
||||
func IsJwtError(err error) bool {
|
||||
return errors.Is(err, ErrJwtNotPresent) ||
|
||||
errors.Is(err, ErrJwtNoConnectPerm) ||
|
||||
errors.Is(err, ErrJwtUuidMismatch) ||
|
||||
errors.Is(err, jwt.ErrExpValidation)
|
||||
}
|
||||
|
||||
// Parses a JWT into a websocket token payload.
|
||||
@@ -84,18 +101,30 @@ func GetHandler(s *server.Server, w http.ResponseWriter, r *http.Request) (*Hand
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
Connection: conn,
|
||||
jwt: nil,
|
||||
server: s,
|
||||
uuid: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Uuid() uuid.UUID {
|
||||
return h.uuid
|
||||
}
|
||||
|
||||
func (h *Handler) SendJson(v *Message) error {
|
||||
// Do not send JSON down the line if the JWT on the connection is not
|
||||
// valid!
|
||||
// Do not send JSON down the line if the JWT on the connection is not valid!
|
||||
if err := h.TokenValid(); err != nil {
|
||||
h.server.Log().WithField("error", err).Warn("invalid JWT detected for server websocket!")
|
||||
h.unsafeSendJson(Message{
|
||||
Event: JwtErrorEvent,
|
||||
Args: []string{err.Error()},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -119,7 +148,23 @@ func (h *Handler) SendJson(v *Message) error {
|
||||
}
|
||||
}
|
||||
|
||||
return h.unsafeSendJson(v)
|
||||
if err := h.unsafeSendJson(v); err != nil {
|
||||
// Not entirely sure how this happens (likely just when there is a ton of console spam)
|
||||
// but I don't care to fix it right now, so just mask the error and throw a warning into
|
||||
// the logs for us to look into later.
|
||||
if errors.Is(err, websocket.ErrCloseSent) {
|
||||
if h.server != nil {
|
||||
h.server.Log().WithField("subsystem", "websocket").
|
||||
WithField("event", v.Event).
|
||||
Warn("failed to send event to websocket: close already sent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sends JSON over the websocket connection, ignoring the authentication state of the
|
||||
@@ -136,7 +181,7 @@ func (h *Handler) unsafeSendJson(v interface{}) error {
|
||||
func (h *Handler) TokenValid() error {
|
||||
j := h.GetJwt()
|
||||
if j == nil {
|
||||
return errors.New("no jwt present")
|
||||
return ErrJwtNotPresent
|
||||
}
|
||||
|
||||
if err := jwt.ExpirationTimeValidator(time.Now())(&j.Payload); err != nil {
|
||||
@@ -144,11 +189,11 @@ func (h *Handler) TokenValid() error {
|
||||
}
|
||||
|
||||
if !j.HasPermission(PermissionConnect) {
|
||||
return errors.New("jwt does not have connect permission")
|
||||
return ErrJwtNoConnectPerm
|
||||
}
|
||||
|
||||
if h.server.Id() != j.GetServerUuid() {
|
||||
return errors.New("jwt server uuid mismatch")
|
||||
return ErrJwtUuidMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -159,7 +204,9 @@ func (h *Handler) TokenValid() error {
|
||||
// error message, otherwise we just send back a standard error message.
|
||||
func (h *Handler) SendErrorJson(msg Message, err error, shouldLog ...bool) error {
|
||||
j := h.GetJwt()
|
||||
expected := errors.Is(err, server.ErrSuspended) || errors.Is(err, server.ErrIsRunning)
|
||||
expected := errors.Is(err, server.ErrSuspended) ||
|
||||
errors.Is(err, server.ErrIsRunning) ||
|
||||
errors.Is(err, filesystem.ErrNotEnoughDiskSpace)
|
||||
|
||||
message := "an unexpected error was encountered while handling this request"
|
||||
if expected || (j != nil && j.HasPermission(PermissionReceiveErrors)) {
|
||||
@@ -172,7 +219,7 @@ func (h *Handler) SendErrorJson(msg Message, err error, shouldLog ...bool) error
|
||||
wsm.Args = []string{m}
|
||||
|
||||
if len(shouldLog) == 0 || (len(shouldLog) == 1 && shouldLog[0] == true) {
|
||||
if !expected {
|
||||
if !expected && !IsJwtError(err) {
|
||||
h.server.Log().WithFields(log.Fields{"event": msg.Event, "error_identifier": u.String(), "error": err}).
|
||||
Error("failed to handle websocket process; an error was encountered processing an event")
|
||||
}
|
||||
@@ -209,11 +256,9 @@ func (h *Handler) GetJwt() *tokens.WebsocketPayload {
|
||||
func (h *Handler) HandleInbound(m Message) error {
|
||||
if m.Event != AuthenticationEvent {
|
||||
if err := h.TokenValid(); err != nil {
|
||||
log.WithField("message", err.Error()).Debug("jwt for server websocket is no longer valid")
|
||||
|
||||
h.unsafeSendJson(Message{
|
||||
Event: ErrorEvent,
|
||||
Args: []string{"could not authenticate client: " + err.Error()},
|
||||
Event: JwtErrorEvent,
|
||||
Args: []string{err.Error()},
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -268,7 +313,7 @@ func (h *Handler) HandleInbound(m Message) error {
|
||||
// Only send the current disk usage if the server is offline, if docker container is running,
|
||||
// Environment#EnableResourcePolling() will send this data to all clients.
|
||||
if state == environment.ProcessOfflineState {
|
||||
_ = h.server.Filesystem.HasSpaceAvailable(false)
|
||||
_ = h.server.Filesystem().HasSpaceAvailable(false)
|
||||
|
||||
b, _ := json.Marshal(h.server.Proc())
|
||||
h.SendJson(&Message{
|
||||
@@ -328,6 +373,16 @@ func (h *Handler) HandleInbound(m Message) error {
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
case SendStatsEvent:
|
||||
{
|
||||
b, _ := json.Marshal(h.server.Proc())
|
||||
h.SendJson(&Message{
|
||||
Event: server.StatsEvent,
|
||||
Args: []string{string(b)},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
case SendCommandEvent:
|
||||
@@ -340,6 +395,18 @@ func (h *Handler) HandleInbound(m Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(dane): should probably add a new process state that is "booting environment" or something
|
||||
// so that we can better handle this and only set the environment to booted once we're attached.
|
||||
//
|
||||
// Or maybe just an IsBooted function?
|
||||
if h.server.GetState() == environment.ProcessStartingState {
|
||||
if e, ok := h.server.Environment.(*docker.Environment); ok {
|
||||
if !e.IsAttached() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h.server.Environment.SendCommand(strings.Join(m.Args, ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"github.com/mholt/archiver/v3"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
@@ -16,19 +18,19 @@ type Archiver struct {
|
||||
Server *Server
|
||||
}
|
||||
|
||||
// ArchivePath returns the path to the server's archive.
|
||||
func (a *Archiver) ArchivePath() string {
|
||||
return filepath.Join(config.Get().System.ArchiveDirectory, a.ArchiveName())
|
||||
// Path returns the path to the server's archive.
|
||||
func (a *Archiver) Path() string {
|
||||
return filepath.Join(config.Get().System.ArchiveDirectory, a.Name())
|
||||
}
|
||||
|
||||
// ArchiveName returns the name of the server's archive.
|
||||
func (a *Archiver) ArchiveName() string {
|
||||
// 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.ArchivePath()); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(a.Path()); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -36,13 +38,21 @@ func (a *Archiver) Exists() bool {
|
||||
}
|
||||
|
||||
// Stat stats the archive file.
|
||||
func (a *Archiver) Stat() (*Stat, error) {
|
||||
return a.Server.Filesystem.unsafeStat(a.ArchivePath())
|
||||
func (a *Archiver) Stat() (*filesystem.Stat, error) {
|
||||
s, err := os.Stat(a.Path())
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &filesystem.Stat{
|
||||
Info: 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()
|
||||
path := a.Server.Filesystem().Path()
|
||||
|
||||
// Get the list of root files and directories to archive.
|
||||
var files []string
|
||||
@@ -52,41 +62,44 @@ func (a *Archiver) Archive() error {
|
||||
}
|
||||
|
||||
for _, file := range fileInfo {
|
||||
f, err := a.Server.Filesystem.SafeJoin(path, file)
|
||||
if err != nil {
|
||||
return err
|
||||
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)
|
||||
}
|
||||
|
||||
stat, err := a.Stat()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if err := a.DeleteIfExists(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the file exists.
|
||||
if stat != nil {
|
||||
if err := os.Remove(a.ArchivePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return archiver.NewTarGz().Archive(files, a.ArchivePath())
|
||||
return archiver.NewTarGz().Archive(files, a.Path())
|
||||
}
|
||||
|
||||
// DeleteIfExists deletes the archive if it exists.
|
||||
func (a *Archiver) DeleteIfExists() error {
|
||||
stat, err := a.Stat()
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
if _, err := a.Stat(); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if the file exists.
|
||||
if stat != nil {
|
||||
if err := os.Remove(a.ArchivePath()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(a.Path()); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -94,7 +107,7 @@ func (a *Archiver) DeleteIfExists() error {
|
||||
|
||||
// Checksum computes a SHA256 checksum of the server's archive.
|
||||
func (a *Archiver) Checksum() (string, error) {
|
||||
file, err := os.Open(a.ArchivePath())
|
||||
file, err := os.Open(a.Path())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *Server) notifyPanelOfBackup(uuid string, ad *backup.ArchiveDetails, suc
|
||||
func (s *Server) getServerwideIgnoredFiles() ([]string, error) {
|
||||
var ignored []string
|
||||
|
||||
f, err := os.Open(path.Join(s.Filesystem.Path(), ".pteroignore"))
|
||||
f, err := os.Open(path.Join(s.Filesystem().Path(), ".pteroignore"))
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
@@ -70,7 +70,7 @@ func (s *Server) GetIncludedBackupFiles(ignored []string) (*backup.IncludedFiles
|
||||
}
|
||||
|
||||
// Get the included files based on the root path and the ignored files provided.
|
||||
return s.Filesystem.GetIncludedFiles(s.Filesystem.Path(), ignored)
|
||||
return s.Filesystem().GetIncludedFiles(s.Filesystem().Path(), ignored)
|
||||
}
|
||||
|
||||
// Performs a server backup and then emits the event over the server websocket. We
|
||||
@@ -83,7 +83,7 @@ func (s *Server) Backup(b backup.BackupInterface) error {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ad, err := b.Generate(inc, s.Filesystem.Path())
|
||||
ad, err := b.Generate(inc, s.Filesystem().Path())
|
||||
if err != nil {
|
||||
if notifyError := s.notifyPanelOfBackup(b.Identifier(), &backup.ArchiveDetails{}, false); notifyError != nil {
|
||||
s.Log().WithFields(log.Fields{
|
||||
|
||||
@@ -23,10 +23,10 @@ type Archive struct {
|
||||
}
|
||||
|
||||
// Creates an archive at dst with all of the files defined in the included files struct.
|
||||
func (a *Archive) Create(dst string, ctx context.Context) (os.FileInfo, error) {
|
||||
func (a *Archive) Create(dst string, ctx context.Context) error {
|
||||
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -75,15 +75,10 @@ func (a *Archive) Create(dst string, ctx context.Context) (os.FileInfo, error) {
|
||||
log.WithField("location", dst).Warn("failed to delete corrupted backup archive")
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(err)
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return st, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Adds a single file to the existing tar archive writer.
|
||||
|
||||
@@ -47,7 +47,7 @@ func (b *LocalBackup) Generate(included *IncludedFiles, prefix string) (*Archive
|
||||
Files: included,
|
||||
}
|
||||
|
||||
if _, err := a.Create(b.Path(), context.Background()); err != nil {
|
||||
if err := a.Create(b.Path(), context.Background()); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *S3Backup) Generate(included *IncludedFiles, prefix string) (*ArchiveDet
|
||||
Files: included,
|
||||
}
|
||||
|
||||
if _, err := a.Create(s.Path(), context.Background()); err != nil {
|
||||
if err := a.Create(s.Path(), context.Background()); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func (s *Server) UpdateConfigurationFiles() {
|
||||
f := cf
|
||||
|
||||
pool.Submit(func() {
|
||||
p, err := s.Filesystem.SafePath(f.FileName)
|
||||
p, err := s.Filesystem().SafePath(f.FileName)
|
||||
if err != nil {
|
||||
s.Log().WithField("error", err).Error("failed to generate safe path for configuration file")
|
||||
|
||||
|
||||
@@ -47,11 +47,12 @@ func (s *Server) Config() *Configuration {
|
||||
return &s.cfg
|
||||
}
|
||||
|
||||
// Returns the amount of disk space available to a server in bytes.
|
||||
func (s *Server) DiskSpace() int64 {
|
||||
s.cfg.mu.RLock()
|
||||
defer s.cfg.mu.RUnlock()
|
||||
|
||||
return s.cfg.Build.DiskSpace
|
||||
return s.cfg.Build.DiskSpace * 1024.0 * 1024.0
|
||||
}
|
||||
|
||||
func (s *Server) MemoryLimit() int64 {
|
||||
|
||||
@@ -1,60 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/mitchellh/colorstring"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/system"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrTooMuchConsoleData = errors.New("console is outputting too much data")
|
||||
|
||||
type ConsoleThrottler struct {
|
||||
sync.RWMutex
|
||||
mu sync.Mutex
|
||||
config.ConsoleThrottles
|
||||
|
||||
// The total number of activations that have occurred thus far.
|
||||
activations uint64
|
||||
|
||||
// The total number of lines that have been sent since the last reset timer period.
|
||||
count uint64
|
||||
|
||||
// Wether or not the console output is being throttled. It is up to calling code to
|
||||
// determine what to do if it is.
|
||||
isThrottled system.AtomicBool
|
||||
|
||||
// The total number of lines processed so far during the given time period.
|
||||
lines uint64
|
||||
|
||||
lastIntervalTime *time.Time
|
||||
lastDecayTime *time.Time
|
||||
timerCancel *context.CancelFunc
|
||||
}
|
||||
|
||||
// Increments the number of activations for a server.
|
||||
func (ct *ConsoleThrottler) AddActivation() uint64 {
|
||||
ct.Lock()
|
||||
defer ct.Unlock()
|
||||
|
||||
ct.activations += 1
|
||||
|
||||
return ct.activations
|
||||
// Resets the state of the throttler.
|
||||
func (ct *ConsoleThrottler) Reset() {
|
||||
atomic.StoreUint64(&ct.count, 0)
|
||||
atomic.StoreUint64(&ct.activations, 0)
|
||||
ct.isThrottled.Set(false)
|
||||
}
|
||||
|
||||
// Decrements the number of activations for a server.
|
||||
func (ct *ConsoleThrottler) RemoveActivation() uint64 {
|
||||
ct.Lock()
|
||||
defer ct.Unlock()
|
||||
// Triggers an activation for a server. You can also decrement the number of activations
|
||||
// by passing a negative number.
|
||||
func (ct *ConsoleThrottler) markActivation(increment bool) uint64 {
|
||||
if !increment {
|
||||
if atomic.LoadUint64(&ct.activations) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
if ct.activations == 0 {
|
||||
return 0
|
||||
// This weird dohicky subtracts 1 from the activation count.
|
||||
return atomic.AddUint64(&ct.activations, ^uint64(0))
|
||||
}
|
||||
|
||||
ct.activations -= 1
|
||||
|
||||
return ct.activations
|
||||
return atomic.AddUint64(&ct.activations, 1)
|
||||
}
|
||||
|
||||
// Increment the total count of lines that we have processed so far.
|
||||
func (ct *ConsoleThrottler) IncrementLineCount() uint64 {
|
||||
return atomic.AddUint64(&ct.lines, 1)
|
||||
// Determines if the console is currently being throttled. Calls to this function can be used to
|
||||
// determine if output should be funneled along to the websocket processes.
|
||||
func (ct *ConsoleThrottler) Throttled() bool {
|
||||
return ct.isThrottled.Get()
|
||||
}
|
||||
|
||||
// Reset the line count to zero.
|
||||
func (ct *ConsoleThrottler) ResetLineCount() {
|
||||
atomic.SwapUint64(&ct.lines, 0)
|
||||
// Starts a timer that runs in a seperate thread and will continually decrement the lines processed
|
||||
// and number of activations, regardless of the current console message volume.
|
||||
func (ct *ConsoleThrottler) StartTimer() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
reset := time.NewTicker(time.Duration(int64(ct.LineResetInterval)) * time.Millisecond)
|
||||
decay := time.NewTicker(time.Duration(int64(ct.DecayInterval)) * time.Millisecond)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
reset.Stop()
|
||||
return
|
||||
case <-reset.C:
|
||||
ct.isThrottled.Set(false)
|
||||
atomic.StoreUint64(&ct.count, 0)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
decay.Stop()
|
||||
return
|
||||
case <-decay.C:
|
||||
ct.markActivation(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ct.timerCancel = &cancel
|
||||
}
|
||||
|
||||
// Stops a running timer processes if one exists. This is only called when the server is deleted since
|
||||
// we want this to always be running. If there is no process currently running nothing will really happen.
|
||||
func (ct *ConsoleThrottler) StopTimer() {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
if ct.timerCancel != nil {
|
||||
c := *ct.timerCancel
|
||||
c()
|
||||
ct.timerCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handles output from a server's console. This code ensures that a server is not outputting
|
||||
@@ -70,30 +121,41 @@ func (ct *ConsoleThrottler) ResetLineCount() {
|
||||
// data all at once. These values are all configurable via the wings configuration file, however the
|
||||
// defaults have been in the wild for almost two years at the time of this writing, so I feel quite
|
||||
// confident in them.
|
||||
func (ct *ConsoleThrottler) Handle() {
|
||||
//
|
||||
// This function returns an error if the server should be stopped due to violating throttle constraints
|
||||
// and a boolean value indicating if a throttle is being violated when it is checked.
|
||||
func (ct *ConsoleThrottler) Increment(onTrigger func()) error {
|
||||
if !ct.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Increment the line count and if we have now output more lines than are allowed, trigger a throttle
|
||||
// activation. Once the throttle is triggered and has passed the kill at value we will trigger a server
|
||||
// stop automatically.
|
||||
if atomic.AddUint64(&ct.count, 1) >= ct.Lines && !ct.Throttled() {
|
||||
ct.isThrottled.Set(true)
|
||||
if ct.markActivation(true) >= ct.MaximumTriggerCount {
|
||||
return ErrTooMuchConsoleData
|
||||
}
|
||||
|
||||
onTrigger()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns the throttler instance for the server or creates a new one.
|
||||
func (s *Server) Throttler() *ConsoleThrottler {
|
||||
s.throttleLock.RLock()
|
||||
s.throttleLock.Lock()
|
||||
defer s.throttleLock.Unlock()
|
||||
|
||||
if s.throttler == nil {
|
||||
// Release the read lock so that we can acquire a normal lock on the process and
|
||||
// make modifications to the throttler.
|
||||
s.throttleLock.RUnlock()
|
||||
|
||||
s.throttleLock.Lock()
|
||||
s.throttler = &ConsoleThrottler{
|
||||
ConsoleThrottles: config.Get().Throttles,
|
||||
}
|
||||
s.throttleLock.Unlock()
|
||||
|
||||
return s.throttler
|
||||
} else {
|
||||
defer s.throttleLock.RUnlock()
|
||||
return s.throttler
|
||||
}
|
||||
|
||||
return s.throttler
|
||||
}
|
||||
|
||||
// Sends output to the server console formatted to appear correctly as being sent
|
||||
|
||||
@@ -1,934 +1,29 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/server/backup"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Error returned when there is a bad path provided to one of the FS calls.
|
||||
type PathResolutionError struct{}
|
||||
|
||||
// Returns the error response in a string form that can be more easily consumed.
|
||||
func (pre PathResolutionError) Error() string {
|
||||
return "invalid path resolution"
|
||||
}
|
||||
|
||||
func IsPathResolutionError(err error) bool {
|
||||
_, ok := err.(PathResolutionError)
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
type Filesystem struct {
|
||||
mu sync.Mutex
|
||||
lookupTimeMu sync.RWMutex
|
||||
|
||||
lastLookupTime time.Time
|
||||
lookupInProgress int32
|
||||
disk int64
|
||||
|
||||
Server *Server
|
||||
}
|
||||
|
||||
// Returns the root path that contains all of a server's data.
|
||||
func (fs *Filesystem) Path() string {
|
||||
return filepath.Join(config.Get().System.Data, fs.Server.Id())
|
||||
}
|
||||
|
||||
// Normalizes a directory being passed in to ensure the user is not able to escape
|
||||
// from their data directory. After normalization if the directory is still within their home
|
||||
// path it is returned. If they managed to "escape" an error will be returned.
|
||||
//
|
||||
// This logic is actually copied over from the SFTP server code. Ideally that eventually
|
||||
// either gets ported into this application, or is able to make use of this package.
|
||||
func (fs *Filesystem) SafePath(p string) (string, error) {
|
||||
var nonExistentPathResolution string
|
||||
|
||||
// Start with a cleaned up path before checking the more complex bits.
|
||||
r := fs.unsafeFilePath(p)
|
||||
|
||||
// At the same time, evaluate the symlink status and determine where this file or folder
|
||||
// is truly pointing to.
|
||||
p, err := filepath.EvalSymlinks(r)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
} else if os.IsNotExist(err) {
|
||||
// The requested directory doesn't exist, so at this point we need to iterate up the
|
||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||
parts := strings.Split(filepath.Dir(r), "/")
|
||||
|
||||
var try string
|
||||
// Range over all of the path parts and form directory pathings from the end
|
||||
// moving up until we have a valid resolution or we run out of paths to try.
|
||||
for k := range parts {
|
||||
try = strings.Join(parts[:(len(parts)-k)], "/")
|
||||
|
||||
if !fs.unsafeIsInDataDirectory(try) {
|
||||
break
|
||||
}
|
||||
|
||||
t, err := filepath.EvalSymlinks(try)
|
||||
if err == nil {
|
||||
nonExistentPathResolution = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the new path doesn't start with their root directory there is clearly an escape
|
||||
// attempt going on, and we should NOT resolve this path for them.
|
||||
if nonExistentPathResolution != "" {
|
||||
if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
|
||||
return "", PathResolutionError{}
|
||||
}
|
||||
|
||||
// If the nonExistentPathResolution variable is not empty then the initial path requested
|
||||
// did not exist and we looped through the pathway until we found a match. At this point
|
||||
// we've confirmed the first matched pathway exists in the root server directory, so we
|
||||
// can go ahead and just return the path that was requested initially.
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// If the requested directory from EvalSymlinks begins with the server root directory go
|
||||
// ahead and return it. If not we'll return an error which will block any further action
|
||||
// on the file.
|
||||
if fs.unsafeIsInDataDirectory(p) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
return "", PathResolutionError{}
|
||||
}
|
||||
|
||||
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
||||
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
||||
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
||||
func (fs *Filesystem) unsafeFilePath(p string) string {
|
||||
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
||||
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
||||
//
|
||||
// This will also trim the existing root path off the beginning of the path passed to
|
||||
// the function since that can get a bit messy.
|
||||
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
||||
}
|
||||
|
||||
// Check that that path string starts with the server data directory path. This function DOES NOT
|
||||
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
||||
// targeted file or folder is not a symlink doing the same thing.
|
||||
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
||||
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
||||
}
|
||||
|
||||
// Helper function to keep some of the codebase a little cleaner. Returns a "safe" version of the path
|
||||
// joined with a file. This is important because you cannot just assume that appending a file to a cleaned
|
||||
// path will result in a cleaned path to that file. For example, imagine you have the following scenario:
|
||||
//
|
||||
// my_bad_file -> symlink:/etc/passwd
|
||||
//
|
||||
// cleaned := SafePath("../../etc") -> "/"
|
||||
// filepath.Join(cleaned, my_bad_file) -> "/my_bad_file"
|
||||
//
|
||||
// You might think that "/my_bad_file" is fine since it isn't pointing to the original "../../etc/my_bad_file".
|
||||
// However, this doesn't account for symlinks where the file might be pointing outside of the directory, so
|
||||
// calling a function such as Chown against it would chown the symlinked location, and not the file within the
|
||||
// Wings daemon.
|
||||
func (fs *Filesystem) SafeJoin(dir string, f os.FileInfo) (string, error) {
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
return fs.SafePath(filepath.Join(dir, f.Name()))
|
||||
}
|
||||
|
||||
return filepath.Join(dir, f.Name()), nil
|
||||
}
|
||||
|
||||
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
|
||||
// fails an error will be returned.
|
||||
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
|
||||
var cleaned []string
|
||||
|
||||
// Simple locker function to avoid racy appends to the array of cleaned paths.
|
||||
var m = new(sync.Mutex)
|
||||
var push = func(c string) {
|
||||
m.Lock()
|
||||
cleaned = append(cleaned, c)
|
||||
m.Unlock()
|
||||
}
|
||||
|
||||
// Create an error group that we can use to run processes in parallel while retaining
|
||||
// the ability to cancel the entire process immediately should any of it fail.
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
|
||||
// of the files, abort the process.
|
||||
for _, p := range paths {
|
||||
// Create copy so we can use it within the goroutine correctly.
|
||||
pi := p
|
||||
|
||||
// Recursively call this function to continue digging through the directory tree within
|
||||
// a separate goroutine. If the context is canceled abort this process.
|
||||
g.Go(func() error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// If the callback returns true, go ahead and keep walking deeper. This allows
|
||||
// us to programmatically continue deeper into directories, or stop digging
|
||||
// if that pathway knows it needs nothing else.
|
||||
if c, err := fs.SafePath(pi); err != nil {
|
||||
return err
|
||||
} else {
|
||||
push(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Block until all of the routines finish and have returned a value.
|
||||
return cleaned, g.Wait()
|
||||
}
|
||||
|
||||
type SpaceCheckingOpts struct {
|
||||
AllowStaleResponse bool
|
||||
}
|
||||
|
||||
// Determines if the directory a file is trying to be added to has enough space available
|
||||
// for the file to be written to.
|
||||
//
|
||||
// Because determining the amount of space being used by a server is a taxing operation we
|
||||
// will load it all up into a cache and pull from that as long as the key is not expired.
|
||||
//
|
||||
// This operation will potentially block unless allowStaleValue is set to true. See the
|
||||
// documentation on DiskUsage for how this affects the call.
|
||||
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
||||
size, err := fs.DiskUsage(allowStaleValue)
|
||||
if err != nil {
|
||||
fs.Server.Log().WithField("error", err).Warn("failed to determine root server directory size")
|
||||
}
|
||||
|
||||
// Determine if their folder size, in bytes, is smaller than the amount of space they've
|
||||
// been allocated.
|
||||
fs.Server.Proc().SetDisk(size)
|
||||
|
||||
space := fs.Server.DiskSpace()
|
||||
// If space is -1 or 0 just return true, means they're allowed unlimited.
|
||||
//
|
||||
// Technically we could skip disk space calculation because we don't need to check if the server exceeds it's limit
|
||||
// but because this method caches the disk usage it would be best to calculate the disk usage and always
|
||||
// return true.
|
||||
if space <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return (size / 1000.0 / 1000.0) <= space
|
||||
}
|
||||
|
||||
// Internal helper function to allow other parts of the codebase to check the total used disk space
|
||||
// as needed without overly taxing the system. This will prioritize the value from the cache to avoid
|
||||
// excessive IO usage. We will only walk the filesystem and determine the size of the directory if there
|
||||
// is no longer a cached value.
|
||||
//
|
||||
// If "allowStaleValue" is set to true, a stale value MAY be returned to the caller if there is an
|
||||
// expired cache value AND there is currently another lookup in progress. If there is no cached value but
|
||||
// no other lookup is in progress, a fresh disk space response will be returned to the caller.
|
||||
//
|
||||
// This is primarily to avoid a bunch of I/O operations from piling up on the server, especially on servers
|
||||
// with a large amount of files.
|
||||
func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
|
||||
// Check if cache is expired.
|
||||
fs.lookupTimeMu.RLock()
|
||||
isValidInCache := fs.lastLookupTime.After(time.Now().Add(time.Second * time.Duration(-1*config.Get().System.DiskCheckInterval)))
|
||||
fs.lookupTimeMu.RUnlock()
|
||||
|
||||
if !isValidInCache {
|
||||
// If we are now allowing a stale response go ahead and perform the lookup and return the fresh
|
||||
// value. This is a blocking operation to the calling process.
|
||||
if !allowStaleValue {
|
||||
return fs.updateCachedDiskUsage()
|
||||
} else if atomic.LoadInt32(&fs.lookupInProgress) == 0 {
|
||||
// Otherwise, if we allow a stale value and there isn't a valid item in the cache and we aren't
|
||||
// currently performing a lookup, just do the disk usage calculation in the background.
|
||||
go func(fs *Filesystem) {
|
||||
if _, err := fs.updateCachedDiskUsage(); err != nil {
|
||||
fs.Server.Log().WithField("error", errors.WithStack(err)).Warn("failed to determine disk usage in go-routine")
|
||||
}
|
||||
}(fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the currently cached value back to the calling function.
|
||||
return atomic.LoadInt64(&fs.disk), nil
|
||||
}
|
||||
|
||||
// Updates the currently used disk space for a server.
|
||||
func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
|
||||
// Obtain an exclusive lock on this process so that we don't unintentionally run it at the same
|
||||
// time as another running process. Once the lock is available it'll read from the cache for the
|
||||
// second call rather than hitting the disk in parallel.
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
// Signal that we're currently updating the disk size so that other calls to the disk checking
|
||||
// functions can determine if they should queue up additional calls to this function. Ensure that
|
||||
// we always set this back to 0 when this process is done executing.
|
||||
atomic.StoreInt32(&fs.lookupInProgress, 1)
|
||||
defer atomic.StoreInt32(&fs.lookupInProgress, 0)
|
||||
|
||||
// If there is no size its either because there is no data (in which case running this function
|
||||
// will have effectively no impact), or there is nothing in the cache, in which case we need to
|
||||
// grab the size of their data directory. This is a taxing operation, so we want to store it in
|
||||
// the cache once we've gotten it.
|
||||
size, err := fs.DirectorySize("/")
|
||||
|
||||
// Always cache the size, even if there is an error. We want to always return that value
|
||||
// so that we don't cause an endless loop of determining the disk size if there is a temporary
|
||||
// error encountered.
|
||||
fs.lookupTimeMu.Lock()
|
||||
fs.lastLookupTime = time.Now()
|
||||
fs.lookupTimeMu.Unlock()
|
||||
|
||||
atomic.StoreInt64(&fs.disk, size)
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
// Determines the directory size of a given location by running parallel tasks to iterate
|
||||
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
|
||||
// on locations with tons of files, so it is recommended that you cache the output.
|
||||
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
|
||||
d, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
var size int64
|
||||
var st syscall.Stat_t
|
||||
|
||||
err = godirwalk.Walk(d, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
// If this is a symlink then resolve the final destination of it before trying to continue walking
|
||||
// over its contents. If it resolves outside the server data directory just skip everything else for
|
||||
// it. Otherwise, allow it to continue.
|
||||
if e.IsSymlink() {
|
||||
if _, err := fs.SafePath(p); err != nil {
|
||||
if IsPathResolutionError(err) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !e.IsDir() {
|
||||
syscall.Lstat(p, &st)
|
||||
atomic.AddInt64(&size, st.Size)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return size, errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Reads a file on the system and returns it as a byte representation in a file
|
||||
// reader. This is not the most memory efficient usage since it will be reading the
|
||||
// entirety of the file into memory.
|
||||
func (fs *Filesystem) Readfile(p string) (io.Reader, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadFile(cleaned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bytes.NewReader(b), nil
|
||||
}
|
||||
|
||||
// Writes a file to the system. If the file does not already exist one will be created.
|
||||
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
var currentSize int64
|
||||
|
||||
// If the file does not exist on the system already go ahead and create the pathway
|
||||
// to it and an empty file. We'll then write to it later on after this completes.
|
||||
if stat, err := os.Stat(cleaned); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cleaned), 0755); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
if stat.IsDir() {
|
||||
return errors.New("cannot write file contents to a directory")
|
||||
}
|
||||
|
||||
currentSize = stat.Size()
|
||||
}
|
||||
|
||||
// This will either create the file if it does not already exist, or open and
|
||||
// truncate the existing file.
|
||||
file, err := os.OpenFile(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 1024*4)
|
||||
sz, err := io.CopyBuffer(file, r, buf)
|
||||
|
||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||
atomic.AddInt64(&fs.disk, sz-currentSize)
|
||||
|
||||
// Finally, chown the file to ensure the permissions don't end up out-of-whack
|
||||
// if we had just created it.
|
||||
return fs.Chown(cleaned)
|
||||
}
|
||||
|
||||
// Defines the stat struct object.
|
||||
type Stat struct {
|
||||
Info os.FileInfo
|
||||
Mimetype string
|
||||
}
|
||||
|
||||
func (s *Stat) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
Modified string `json:"modified"`
|
||||
Mode string `json:"mode"`
|
||||
Size int64 `json:"size"`
|
||||
Directory bool `json:"directory"`
|
||||
File bool `json:"file"`
|
||||
Symlink bool `json:"symlink"`
|
||||
Mime string `json:"mime"`
|
||||
}{
|
||||
Name: s.Info.Name(),
|
||||
Created: s.CTime().Format(time.RFC3339),
|
||||
Modified: s.Info.ModTime().Format(time.RFC3339),
|
||||
Mode: s.Info.Mode().String(),
|
||||
Size: s.Info.Size(),
|
||||
Directory: s.Info.IsDir(),
|
||||
File: !s.Info.IsDir(),
|
||||
Symlink: s.Info.Mode().Perm()&os.ModeSymlink != 0,
|
||||
Mime: s.Mimetype,
|
||||
})
|
||||
}
|
||||
|
||||
// Stats a file or folder and returns the base stat object from go along with the
|
||||
// MIME data that can be used for editing files.
|
||||
func (fs *Filesystem) Stat(p string) (*Stat, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fs.unsafeStat(cleaned)
|
||||
}
|
||||
|
||||
func (fs *Filesystem) unsafeStat(p string) (*Stat, error) {
|
||||
s, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m *mimetype.MIME
|
||||
if !s.IsDir() {
|
||||
m, err = mimetype.DetectFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
st := &Stat{
|
||||
Info: s,
|
||||
Mimetype: "inode/directory",
|
||||
}
|
||||
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Creates a new directory (name) at a specified path (p) for the server.
|
||||
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
||||
cleaned, err := fs.SafePath(path.Join(p, name))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return os.MkdirAll(cleaned, 0755)
|
||||
}
|
||||
|
||||
// Moves (or renames) a file or directory.
|
||||
func (fs *Filesystem) Rename(from string, to string) error {
|
||||
cleanedFrom, err := fs.SafePath(from)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
cleanedTo, err := fs.SafePath(to)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// If the target file or directory already exists the rename function will fail, so just
|
||||
// bail out now.
|
||||
if _, err := os.Stat(cleanedTo); err == nil {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
if cleanedTo == fs.Path() {
|
||||
return errors.New("attempting to rename into an invalid directory space")
|
||||
}
|
||||
|
||||
d := strings.TrimSuffix(cleanedTo, path.Base(cleanedTo))
|
||||
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
|
||||
// we're not at the root directory level.
|
||||
if d != fs.Path() {
|
||||
if mkerr := os.MkdirAll(d, 0644); mkerr != nil {
|
||||
return errors.Wrap(mkerr, "failed to create directory structure for file rename")
|
||||
}
|
||||
}
|
||||
|
||||
return os.Rename(cleanedFrom, cleanedTo)
|
||||
}
|
||||
|
||||
// Recursively iterates over a file or directory and sets the permissions on all of the
|
||||
// underlying files. Iterate over all of the files and directories. If it is a file just
|
||||
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
|
||||
// we've run out of directories to dig into.
|
||||
func (fs *Filesystem) Chown(path string) error {
|
||||
cleaned, err := fs.SafePath(path)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
uid := config.Get().System.User.Uid
|
||||
gid := config.Get().System.User.Gid
|
||||
|
||||
// Start by just chowning the initial path that we received.
|
||||
if err := os.Chown(cleaned, uid, gid); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// If this is not a directory we can now return from the function, there is nothing
|
||||
// left that we need to do.
|
||||
if st, _ := os.Stat(cleaned); !st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If this was a directory, begin walking over its contents recursively and ensure that all
|
||||
// of the subfiles and directories get their permissions updated as well.
|
||||
return godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
// Do not attempt to chmod a symlink. Go's os.Chown function will affect the symlink
|
||||
// so if it points to a location outside the data directory the user would be able to
|
||||
// (un)intentionally modify that files permissions.
|
||||
if e.IsSymlink() {
|
||||
if e.IsDir() {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Chown(p, uid, gid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Copies a given file to the same location and appends a suffix to the file to indicate that
|
||||
// it has been copied.
|
||||
//
|
||||
// @todo need to get an exclusive lock on the file.
|
||||
func (fs *Filesystem) Copy(p string) error {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if s, err := os.Stat(cleaned); err != nil {
|
||||
return errors.WithStack(err)
|
||||
} else if s.IsDir() || !s.Mode().IsRegular() {
|
||||
// If this is a directory or not a regular file, just throw a not-exist error
|
||||
// since anything calling this function should understand what that means.
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
base := filepath.Base(cleaned)
|
||||
relative := strings.TrimSuffix(strings.TrimPrefix(cleaned, fs.Path()), base)
|
||||
extension := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
|
||||
// Begin looping up to 50 times to try and create a unique copy file name. This will take
|
||||
// an input of "file.txt" and generate "file copy.txt". If that name is already taken, it will
|
||||
// then try to write "file copy 2.txt" and so on, until reaching 50 loops. At that point we
|
||||
// won't waste anymore time, just use the current timestamp and make that copy.
|
||||
//
|
||||
// Could probably make this more efficient by checking if there are any files matching the copy
|
||||
// pattern, and trying to find the highest number and then incrementing it by one rather than
|
||||
// looping endlessly.
|
||||
var i int
|
||||
copySuffix := " copy"
|
||||
for i = 0; i < 51; i++ {
|
||||
if i > 0 {
|
||||
copySuffix = " copy " + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
tryName := fmt.Sprintf("%s%s%s", name, copySuffix, extension)
|
||||
tryLocation, err := fs.SafePath(path.Join(relative, tryName))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// If the file exists, continue to the next loop, otherwise we're good to start a copy.
|
||||
if _, err := os.Stat(tryLocation); err != nil && !os.IsNotExist(err) {
|
||||
return errors.WithStack(err)
|
||||
} else if os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
|
||||
if i == 50 {
|
||||
copySuffix = "." + time.Now().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
finalPath, err := fs.SafePath(path.Join(relative, fmt.Sprintf("%s%s%s", name, copySuffix, extension)))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
source, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
dest, err := os.Create(finalPath)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
buf := make([]byte, 1024*4)
|
||||
if _, err := io.CopyBuffer(dest, source, buf); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes a file or folder from the system. Prevents the user from accidentally
|
||||
// (or maliciously) removing their root server data directory.
|
||||
func (fs *Filesystem) Delete(p string) error {
|
||||
// This is one of the few (only?) places in the codebase where we're explicitly not using
|
||||
// the SafePath functionality when working with user provided input. If we did, you would
|
||||
// not be able to delete a file that is a symlink pointing to a location outside of the data
|
||||
// directory.
|
||||
//
|
||||
// We also want to avoid resolving a symlink that points _within_ the data directory and thus
|
||||
// deleting the actual source file for the symlink rather than the symlink itself. For these
|
||||
// purposes just resolve the actual file path using filepath.Join() and confirm that the path
|
||||
// exists within the data directory.
|
||||
resolved := fs.unsafeFilePath(p)
|
||||
if !fs.unsafeIsInDataDirectory(resolved) {
|
||||
return PathResolutionError{}
|
||||
}
|
||||
|
||||
// Block any whoopsies.
|
||||
if resolved == fs.Path() {
|
||||
return errors.New("cannot delete root server directory")
|
||||
}
|
||||
|
||||
if st, err := os.Stat(resolved); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fs.Server.Log().WithField("error", err).WithField("path", resolved).Warn("error while attempting to stat file before deletion")
|
||||
}
|
||||
} else {
|
||||
if !st.IsDir() {
|
||||
atomic.SwapInt64(&fs.disk, -st.Size())
|
||||
} else {
|
||||
go func(st os.FileInfo, resolved string) {
|
||||
if s, err := fs.DirectorySize(resolved); err == nil {
|
||||
atomic.AddInt64(&fs.disk, -s)
|
||||
}
|
||||
}(st, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
return os.RemoveAll(resolved)
|
||||
}
|
||||
|
||||
// Lists the contents of a given directory and returns stat information about each
|
||||
// file and folder within it.
|
||||
func (fs *Filesystem) ListDirectory(p string) ([]*Stat, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := ioutil.ReadDir(cleaned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// You must initialize the output of this directory as a non-nil value otherwise
|
||||
// when it is marshaled into a JSON object you'll just get 'null' back, which will
|
||||
// break the panel badly.
|
||||
out := make([]*Stat, len(files))
|
||||
|
||||
// Iterate over all of the files and directories returned and perform an async process
|
||||
// to get the mime-type for them all.
|
||||
for i, file := range files {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, f os.FileInfo) {
|
||||
defer wg.Done()
|
||||
|
||||
var m *mimetype.MIME
|
||||
var d = "inode/directory"
|
||||
if !f.IsDir() {
|
||||
cleanedp, _ := fs.SafeJoin(cleaned, f)
|
||||
if cleanedp != "" {
|
||||
m, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
|
||||
} else {
|
||||
// Just pass this for an unknown type because the file could not safely be resolved within
|
||||
// the server data path.
|
||||
d = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
st := &Stat{
|
||||
Info: f,
|
||||
Mimetype: d,
|
||||
}
|
||||
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
|
||||
out[idx] = st
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sort the output alphabetically to begin with since we've run the output
|
||||
// through an asynchronous process and the order is gonna be very random.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Info.Name() == out[j].Info.Name() || out[i].Info.Name() > out[j].Info.Name() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Then, sort it so that directories are listed first in the output. Everything
|
||||
// will continue to be alphabetized at this point.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].Info.IsDir()
|
||||
})
|
||||
|
||||
return out, nil
|
||||
func (s *Server) Filesystem() *filesystem.Filesystem {
|
||||
return s.fs
|
||||
}
|
||||
|
||||
// Ensures that the data directory for the server instance exists.
|
||||
func (fs *Filesystem) EnsureDataDirectory() error {
|
||||
if _, err := os.Stat(fs.Path()); err != nil && !os.IsNotExist(err) {
|
||||
func (s *Server) EnsureDataDirectoryExists() error {
|
||||
if _, err := os.Stat(s.fs.Path()); err != nil && !os.IsNotExist(err) {
|
||||
return errors.WithStack(err)
|
||||
} else if err != nil {
|
||||
// Create the server data directory because it does not currently exist
|
||||
// on the system.
|
||||
if err := os.MkdirAll(fs.Path(), 0700); err != nil {
|
||||
if err := os.MkdirAll(s.fs.Path(), 0700); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Given a directory, iterate through all of the files and folders within it and determine
|
||||
// if they should be included in the output based on an array of ignored matches. This uses
|
||||
// standard .gitignore formatting to make that determination.
|
||||
//
|
||||
// If no ignored files are passed through you'll get the entire directory listing.
|
||||
func (fs *Filesystem) GetIncludedFiles(dir string, ignored []string) (*backup.IncludedFiles, error) {
|
||||
cleaned, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i, err := ignore.CompileIgnoreLines(ignored...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Walk through all of the files and directories on a server. This callback only returns
|
||||
// files found, and will keep walking deeper and deeper into directories.
|
||||
inc := new(backup.IncludedFiles)
|
||||
|
||||
err = godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
sp := p
|
||||
if e.IsSymlink() {
|
||||
sp, err = fs.SafePath(p)
|
||||
if err != nil {
|
||||
if IsPathResolutionError(err) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Only push files into the result array since archives can't create an empty directory within them.
|
||||
if !e.IsDir() {
|
||||
// Avoid unnecessary parsing if there are no ignored files, nothing will match anyways
|
||||
// so no reason to call the function.
|
||||
if len(ignored) == 0 || !i.MatchesPath(strings.TrimPrefix(sp, fs.Path()+"/")) {
|
||||
inc.Push(sp)
|
||||
}
|
||||
}
|
||||
|
||||
// We can't just abort if the path is technically ignored. It is possible there is a nested
|
||||
// file or folder that should not be excluded, so in this case we need to just keep going
|
||||
// until we get to a final state.
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return inc, errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Compresses all of the files matching the given paths in the specified directory. This function
|
||||
// also supports passing nested paths to only compress certain files and folders when working in
|
||||
// a larger directory. This effectively creates a local backup, but rather than ignoring specific
|
||||
// files and folders, it takes an allow-list of files and folders.
|
||||
//
|
||||
// All paths are relative to the dir that is passed in as the first argument, and the compressed
|
||||
// file will be placed at that location named `archive-{date}.tar.gz`.
|
||||
func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, error) {
|
||||
cleanedRootDir, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Take all of the paths passed in and merge them together with the root directory we've gotten.
|
||||
for i, p := range paths {
|
||||
paths[i] = filepath.Join(cleanedRootDir, p)
|
||||
}
|
||||
|
||||
cleaned, err := fs.ParallelSafePath(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inc := new(backup.IncludedFiles)
|
||||
// Iterate over all of the cleaned paths and merge them into a large object of final file
|
||||
// paths to pass into the archiver. As directories are encountered this will drop into them
|
||||
// and look for all of the files.
|
||||
for _, p := range cleaned {
|
||||
f, err := os.Stat(p)
|
||||
if err != nil {
|
||||
fs.Server.Log().WithField("error", err).WithField("path", p).Debug("failed to stat file or directory for compression")
|
||||
continue
|
||||
}
|
||||
|
||||
if !f.IsDir() {
|
||||
inc.Push(p)
|
||||
} else {
|
||||
err := godirwalk.Walk(p, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
sp := p
|
||||
if e.IsSymlink() {
|
||||
// Ensure that any symlinks are properly resolved to their final destination. If
|
||||
// that destination is outside the server directory skip over this entire item, otherwise
|
||||
// use the resolved location for the rest of this function.
|
||||
sp, err = fs.SafePath(p)
|
||||
if err != nil {
|
||||
if IsPathResolutionError(err) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !e.IsDir() {
|
||||
inc.Push(sp)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a := &backup.Archive{TrimPrefix: fs.Path(), Files: inc}
|
||||
|
||||
d := path.Join(cleanedRootDir, fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")))
|
||||
|
||||
return a.Create(d, context.Background())
|
||||
}
|
||||
|
||||
// Handle errors encountered when walking through directories.
|
||||
//
|
||||
// If there is a path resolution error just skip the item entirely. Only return this for a
|
||||
// directory, otherwise return nil. Returning this error for a file will stop the walking
|
||||
// for the remainder of the directory. This is assuming an os.FileInfo struct was even returned.
|
||||
func (fs *Filesystem) handleWalkerError(err error, f os.FileInfo) error {
|
||||
if !IsPathResolutionError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if f != nil && f.IsDir() {
|
||||
return filepath.SkipDir
|
||||
|
||||
if err := s.fs.Chown("/"); err != nil {
|
||||
s.Log().WithField("error", err).Warn("failed to chown server data directory")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
163
server/filesystem/compress.go
Normal file
163
server/filesystem/compress.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/server/backup"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Given a directory, iterate through all of the files and folders within it and determine
|
||||
// if they should be included in the output based on an array of ignored matches. This uses
|
||||
// standard .gitignore formatting to make that determination.
|
||||
//
|
||||
// If no ignored files are passed through you'll get the entire directory listing.
|
||||
func (fs *Filesystem) GetIncludedFiles(dir string, ignored []string) (*backup.IncludedFiles, error) {
|
||||
cleaned, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i, err := ignore.CompileIgnoreLines(ignored...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Walk through all of the files and directories on a server. This callback only returns
|
||||
// files found, and will keep walking deeper and deeper into directories.
|
||||
inc := new(backup.IncludedFiles)
|
||||
|
||||
err = godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
sp := p
|
||||
if e.IsSymlink() {
|
||||
sp, err = fs.SafePath(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBadPathResolution) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Only push files into the result array since archives can't create an empty directory within them.
|
||||
if !e.IsDir() {
|
||||
// Avoid unnecessary parsing if there are no ignored files, nothing will match anyways
|
||||
// so no reason to call the function.
|
||||
if len(ignored) == 0 || !i.MatchesPath(strings.TrimPrefix(sp, fs.Path()+"/")) {
|
||||
inc.Push(sp)
|
||||
}
|
||||
}
|
||||
|
||||
// We can't just abort if the path is technically ignored. It is possible there is a nested
|
||||
// file or folder that should not be excluded, so in this case we need to just keep going
|
||||
// until we get to a final state.
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return inc, errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Compresses all of the files matching the given paths in the specified directory. This function
|
||||
// also supports passing nested paths to only compress certain files and folders when working in
|
||||
// a larger directory. This effectively creates a local backup, but rather than ignoring specific
|
||||
// files and folders, it takes an allow-list of files and folders.
|
||||
//
|
||||
// All paths are relative to the dir that is passed in as the first argument, and the compressed
|
||||
// file will be placed at that location named `archive-{date}.tar.gz`.
|
||||
func (fs *Filesystem) CompressFiles(dir string, paths []string) (os.FileInfo, error) {
|
||||
cleanedRootDir, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Take all of the paths passed in and merge them together with the root directory we've gotten.
|
||||
for i, p := range paths {
|
||||
paths[i] = filepath.Join(cleanedRootDir, p)
|
||||
}
|
||||
|
||||
cleaned, err := fs.ParallelSafePath(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inc := new(backup.IncludedFiles)
|
||||
// Iterate over all of the cleaned paths and merge them into a large object of final file
|
||||
// paths to pass into the archiver. As directories are encountered this will drop into them
|
||||
// and look for all of the files.
|
||||
for _, p := range cleaned {
|
||||
f, err := os.Stat(p)
|
||||
if err != nil {
|
||||
fs.error(err).WithField("path", p).Debug("failed to stat file or directory for compression")
|
||||
continue
|
||||
}
|
||||
|
||||
if !f.IsDir() {
|
||||
inc.Push(p)
|
||||
} else {
|
||||
err := godirwalk.Walk(p, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
sp := p
|
||||
if e.IsSymlink() {
|
||||
// Ensure that any symlinks are properly resolved to their final destination. If
|
||||
// that destination is outside the server directory skip over this entire item, otherwise
|
||||
// use the resolved location for the rest of this function.
|
||||
sp, err = fs.SafePath(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBadPathResolution) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !e.IsDir() {
|
||||
inc.Push(sp)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a := &backup.Archive{TrimPrefix: fs.Path(), Files: inc}
|
||||
d := path.Join(cleanedRootDir, fmt.Sprintf("archive-%s.tar.gz", strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "")))
|
||||
|
||||
if err := a.Create(d, context.Background()); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
f, err := os.Stat(d)
|
||||
if err != nil {
|
||||
_ = os.Remove(d)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := fs.hasSpaceFor(f.Size()); err != nil {
|
||||
_ = os.Remove(d)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fs.addDisk(f.Size())
|
||||
|
||||
return f, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) (bool, error) {
|
||||
// Don't waste time trying to determine this if we know the server will have the space for
|
||||
// it since there is no limit.
|
||||
if fs.Server.DiskSpace() <= 0 {
|
||||
if fs.MaxDisk() <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -33,13 +34,23 @@ func (fs *Filesystem) SpaceAvailableForDecompression(dir string, file string) (b
|
||||
|
||||
var size int64
|
||||
// Walk over the archive and figure out just how large the final output would be from unarchiving it.
|
||||
archiver.Walk(source, func(f archiver.File) error {
|
||||
atomic.AddInt64(&size, f.Size())
|
||||
err = archiver.Walk(source, func(f archiver.File) error {
|
||||
if atomic.AddInt64(&size, f.Size())+dirSize > fs.MaxDisk() {
|
||||
return ErrNotEnoughDiskSpace
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return ((dirSize + size) / 1000.0 / 1000.0) <= fs.Server.DiskSpace(), errors.WithStack(err)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "format ") {
|
||||
return false, ErrUnknownArchiveFormat
|
||||
}
|
||||
|
||||
return false, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return true, errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Decompress a file in a given directory by using the archiver tool to infer the file
|
||||
@@ -60,7 +71,7 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
|
||||
// Walk over all of the files spinning up an additional go-routine for each file we've encountered
|
||||
// and then extract that file from the archive and write it to the disk. If any part of this process
|
||||
// encounters an error the entire process will be stopped.
|
||||
return archiver.Walk(source, func(f archiver.File) error {
|
||||
err = archiver.Walk(source, func(f archiver.File) error {
|
||||
// Don't waste time with directories, we don't need to create them if they have no contents, and
|
||||
// we will ensure the directory exists when opening the file for writing anyways.
|
||||
if f.IsDir() {
|
||||
@@ -80,6 +91,20 @@ func (fs *Filesystem) DecompressFile(dir string, file string) error {
|
||||
return errors.New(fmt.Sprintf("could not parse underlying data source with type %s", reflect.TypeOf(s).String()))
|
||||
}
|
||||
|
||||
return errors.Wrap(fs.Writefile(name, f), "could not extract file from archive")
|
||||
p, err := fs.SafePath(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to generate a safe path to server file")
|
||||
}
|
||||
|
||||
return errors.Wrap(fs.Writefile(p, f), "could not extract file from archive")
|
||||
})
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "format ") {
|
||||
return errors.WithStack(ErrUnknownArchiveFormat)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
221
server/filesystem/disk_space.go
Normal file
221
server/filesystem/disk_space.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"github.com/apex/log"
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SpaceCheckingOpts struct {
|
||||
AllowStaleResponse bool
|
||||
}
|
||||
|
||||
type usageLookupTime struct {
|
||||
sync.RWMutex
|
||||
value time.Time
|
||||
}
|
||||
|
||||
// Update the last time that a disk space lookup was performed.
|
||||
func (ult *usageLookupTime) Set(t time.Time) {
|
||||
ult.Lock()
|
||||
ult.value = t
|
||||
ult.Unlock()
|
||||
}
|
||||
|
||||
// Get the last time that we performed a disk space usage lookup.
|
||||
func (ult *usageLookupTime) Get() time.Time {
|
||||
ult.RLock()
|
||||
defer ult.RUnlock()
|
||||
|
||||
return ult.value
|
||||
}
|
||||
|
||||
// Returns the maximum amount of disk space that this Filesystem instance is allowed to use.
|
||||
func (fs *Filesystem) MaxDisk() int64 {
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
return fs.diskLimit
|
||||
}
|
||||
|
||||
// Sets the disk space limit for this Filesystem instance.
|
||||
func (fs *Filesystem) SetDiskLimit(i int64) {
|
||||
fs.mu.Lock()
|
||||
fs.diskLimit = i
|
||||
fs.mu.Unlock()
|
||||
}
|
||||
|
||||
// Determines if the directory a file is trying to be added to has enough space available
|
||||
// for the file to be written to.
|
||||
//
|
||||
// Because determining the amount of space being used by a server is a taxing operation we
|
||||
// will load it all up into a cache and pull from that as long as the key is not expired.
|
||||
//
|
||||
// This operation will potentially block unless allowStaleValue is set to true. See the
|
||||
// documentation on DiskUsage for how this affects the call.
|
||||
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
|
||||
size, err := fs.DiskUsage(allowStaleValue)
|
||||
if err != nil {
|
||||
log.WithField("root", fs.root).WithField("error", err).Warn("failed to determine root fs directory size")
|
||||
}
|
||||
|
||||
// If space is -1 or 0 just return true, means they're allowed unlimited.
|
||||
//
|
||||
// Technically we could skip disk space calculation because we don't need to check if the
|
||||
// server exceeds it's limit but because this method caches the disk usage it would be best
|
||||
// to calculate the disk usage and always return true.
|
||||
if fs.MaxDisk() == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return size <= fs.MaxDisk()
|
||||
}
|
||||
|
||||
// Internal helper function to allow other parts of the codebase to check the total used disk space
|
||||
// as needed without overly taxing the system. This will prioritize the value from the cache to avoid
|
||||
// excessive IO usage. We will only walk the filesystem and determine the size of the directory if there
|
||||
// is no longer a cached value.
|
||||
//
|
||||
// If "allowStaleValue" is set to true, a stale value MAY be returned to the caller if there is an
|
||||
// expired cache value AND there is currently another lookup in progress. If there is no cached value but
|
||||
// no other lookup is in progress, a fresh disk space response will be returned to the caller.
|
||||
//
|
||||
// This is primarily to avoid a bunch of I/O operations from piling up on the server, especially on servers
|
||||
// with a large amount of files.
|
||||
func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
|
||||
if !fs.lastLookupTime.Get().After(time.Now().Add(time.Second * fs.diskCheckInterval * -1)) {
|
||||
// If we are now allowing a stale response go ahead and perform the lookup and return the fresh
|
||||
// value. This is a blocking operation to the calling process.
|
||||
if !allowStaleValue {
|
||||
return fs.updateCachedDiskUsage()
|
||||
} else if !fs.lookupInProgress.Get() {
|
||||
// Otherwise, if we allow a stale value and there isn't a valid item in the cache and we aren't
|
||||
// currently performing a lookup, just do the disk usage calculation in the background.
|
||||
go func(fs *Filesystem) {
|
||||
if _, err := fs.updateCachedDiskUsage(); err != nil {
|
||||
log.WithField("root", fs.root).WithField("error", err).Warn("failed to update fs disk usage from within routine")
|
||||
}
|
||||
}(fs)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the currently cached value back to the calling function.
|
||||
return atomic.LoadInt64(&fs.diskUsed), nil
|
||||
}
|
||||
|
||||
// Updates the currently used disk space for a server.
|
||||
func (fs *Filesystem) updateCachedDiskUsage() (int64, error) {
|
||||
// Obtain an exclusive lock on this process so that we don't unintentionally run it at the same
|
||||
// time as another running process. Once the lock is available it'll read from the cache for the
|
||||
// second call rather than hitting the disk in parallel.
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
// Signal that we're currently updating the disk size so that other calls to the disk checking
|
||||
// functions can determine if they should queue up additional calls to this function. Ensure that
|
||||
// we always set this back to "false" when this process is done executing.
|
||||
fs.lookupInProgress.Set(true)
|
||||
defer fs.lookupInProgress.Set(false)
|
||||
|
||||
// If there is no size its either because there is no data (in which case running this function
|
||||
// will have effectively no impact), or there is nothing in the cache, in which case we need to
|
||||
// grab the size of their data directory. This is a taxing operation, so we want to store it in
|
||||
// the cache once we've gotten it.
|
||||
size, err := fs.DirectorySize("/")
|
||||
|
||||
// Always cache the size, even if there is an error. We want to always return that value
|
||||
// so that we don't cause an endless loop of determining the disk size if there is a temporary
|
||||
// error encountered.
|
||||
fs.lastLookupTime.Set(time.Now())
|
||||
|
||||
atomic.StoreInt64(&fs.diskUsed, size)
|
||||
|
||||
return size, err
|
||||
}
|
||||
|
||||
// Determines the directory size of a given location by running parallel tasks to iterate
|
||||
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
|
||||
// on locations with tons of files, so it is recommended that you cache the output.
|
||||
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
|
||||
d, err := fs.SafePath(dir)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
var size int64
|
||||
var st syscall.Stat_t
|
||||
|
||||
err = godirwalk.Walk(d, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
// If this is a symlink then resolve the final destination of it before trying to continue walking
|
||||
// over its contents. If it resolves outside the server data directory just skip everything else for
|
||||
// it. Otherwise, allow it to continue.
|
||||
if e.IsSymlink() {
|
||||
if _, err := fs.SafePath(p); err != nil {
|
||||
if errors.Is(err, ErrBadPathResolution) {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !e.IsDir() {
|
||||
syscall.Lstat(p, &st)
|
||||
atomic.AddInt64(&size, st.Size)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return size, errors.WithStack(err)
|
||||
}
|
||||
|
||||
// Helper function to determine if a server has space available for a file of a given size.
|
||||
// If space is available, no error will be returned, otherwise an ErrNotEnoughSpace error
|
||||
// will be raised.
|
||||
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 ErrNotEnoughDiskSpace
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Updates the disk usage for the Filesystem instance.
|
||||
func (fs *Filesystem) addDisk(i int64) int64 {
|
||||
var size = atomic.LoadInt64(&fs.diskUsed)
|
||||
// Sorry go gods. This is ugly but the best approach I can come up with for right
|
||||
// now without completely re-evaluating the logic we use for determining disk space.
|
||||
//
|
||||
// Normally I would just be using the atomic load right below, but I'm not sure about
|
||||
// the scenarios where it is 0 because nothing has run that would trigger a disk size
|
||||
// calculation?
|
||||
//
|
||||
// Perhaps that isn't even a concern for the sake of this?
|
||||
if !fs.isTest {
|
||||
size, _ = fs.DiskUsage(true)
|
||||
}
|
||||
|
||||
// If we're dropping below 0 somehow just cap it to 0.
|
||||
if (size + i) < 0 {
|
||||
return atomic.SwapInt64(&fs.diskUsed, 0)
|
||||
}
|
||||
|
||||
return atomic.AddInt64(&fs.diskUsed, i)
|
||||
}
|
||||
35
server/filesystem/errors.go
Normal file
35
server/filesystem/errors.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"github.com/apex/log"
|
||||
"github.com/pkg/errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var ErrIsDirectory = errors.New("filesystem: is a directory")
|
||||
var ErrNotEnoughDiskSpace = errors.New("filesystem: not enough disk space")
|
||||
var ErrBadPathResolution = errors.New("filesystem: invalid path resolution")
|
||||
var ErrUnknownArchiveFormat = errors.New("filesystem: unknown archive format")
|
||||
|
||||
// Generates an error logger instance with some basic information.
|
||||
func (fs *Filesystem) error(err error) *log.Entry {
|
||||
return log.WithField("subsystem", "filesystem").WithField("root", fs.root).WithField("error", err)
|
||||
}
|
||||
|
||||
// Handle errors encountered when walking through directories.
|
||||
//
|
||||
// If there is a path resolution error just skip the item entirely. Only return this for a
|
||||
// directory, otherwise return nil. Returning this error for a file will stop the walking
|
||||
// for the remainder of the directory. This is assuming an os.FileInfo struct was even returned.
|
||||
func (fs *Filesystem) handleWalkerError(err error, f os.FileInfo) error {
|
||||
if !errors.Is(err, ErrBadPathResolution) {
|
||||
return err
|
||||
}
|
||||
|
||||
if f != nil && f.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
461
server/filesystem/filesystem.go
Normal file
461
server/filesystem/filesystem.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/karrick/godirwalk"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/system"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Filesystem struct {
|
||||
mu sync.RWMutex
|
||||
lastLookupTime *usageLookupTime
|
||||
lookupInProgress system.AtomicBool
|
||||
diskUsed int64
|
||||
diskCheckInterval time.Duration
|
||||
|
||||
// The maximum amount of disk space (in bytes) that this Filesystem instance can use.
|
||||
diskLimit int64
|
||||
|
||||
// The root data directory path for this Filesystem instance.
|
||||
root string
|
||||
|
||||
isTest bool
|
||||
}
|
||||
|
||||
// Creates a new Filesystem instance for a given server.
|
||||
func New(root string, size int64) *Filesystem {
|
||||
return &Filesystem{
|
||||
root: root,
|
||||
diskLimit: size,
|
||||
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),
|
||||
lastLookupTime: &usageLookupTime{},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the root path for the Filesystem instance.
|
||||
func (fs *Filesystem) Path() string {
|
||||
return fs.root
|
||||
}
|
||||
|
||||
// Reads a file on the system and returns it as a byte representation in a file
|
||||
// reader. This is not the most memory efficient usage since it will be reading the
|
||||
// entirety of the file into memory.
|
||||
func (fs *Filesystem) Readfile(p string, w io.Writer) error {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if st, err := os.Stat(cleaned); err != nil {
|
||||
return err
|
||||
} else if st.IsDir() {
|
||||
return ErrIsDirectory
|
||||
}
|
||||
|
||||
f, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = bufio.NewReader(f).WriteTo(w)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Writes a file to the system. If the file does not already exist one will be created.
|
||||
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
var currentSize int64
|
||||
// If the file does not exist on the system already go ahead and create the pathway
|
||||
// to it and an empty file. We'll then write to it later on after this completes.
|
||||
if stat, err := os.Stat(cleaned); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cleaned), 0755); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
} else {
|
||||
if stat.IsDir() {
|
||||
return ErrIsDirectory
|
||||
}
|
||||
|
||||
currentSize = stat.Size()
|
||||
}
|
||||
|
||||
br := bufio.NewReader(r)
|
||||
// Check that the new size we're writing to the disk can fit. If there is currently a file
|
||||
// we'll subtract that current file size from the size of the buffer to determine the amount
|
||||
// of new data we're writing (or amount we're removing if smaller).
|
||||
if err := fs.hasSpaceFor(int64(br.Size()) - currentSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
o := &fileOpener{}
|
||||
// This will either create the file if it does not already exist, or open and
|
||||
// truncate the existing file.
|
||||
file, err := o.open(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 1024*4)
|
||||
sz, err := io.CopyBuffer(file, r, buf)
|
||||
|
||||
// Adjust the disk usage to account for the old size and the new size of the file.
|
||||
fs.addDisk(sz - currentSize)
|
||||
|
||||
// Finally, chown the file to ensure the permissions don't end up out-of-whack
|
||||
// if we had just created it.
|
||||
return fs.Chown(cleaned)
|
||||
}
|
||||
|
||||
// Creates a new directory (name) at a specified path (p) for the server.
|
||||
func (fs *Filesystem) CreateDirectory(name string, p string) error {
|
||||
cleaned, err := fs.SafePath(path.Join(p, name))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return os.MkdirAll(cleaned, 0755)
|
||||
}
|
||||
|
||||
// Moves (or renames) a file or directory.
|
||||
func (fs *Filesystem) Rename(from string, to string) error {
|
||||
cleanedFrom, err := fs.SafePath(from)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
cleanedTo, err := fs.SafePath(to)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// If the target file or directory already exists the rename function will fail, so just
|
||||
// bail out now.
|
||||
if _, err := os.Stat(cleanedTo); err == nil {
|
||||
return os.ErrExist
|
||||
}
|
||||
|
||||
if cleanedTo == fs.Path() {
|
||||
return errors.New("attempting to rename into an invalid directory space")
|
||||
}
|
||||
|
||||
d := strings.TrimSuffix(cleanedTo, path.Base(cleanedTo))
|
||||
// Ensure that the directory we're moving into exists correctly on the system. Only do this if
|
||||
// we're not at the root directory level.
|
||||
if d != fs.Path() {
|
||||
if mkerr := os.MkdirAll(d, 0755); mkerr != nil {
|
||||
return errors.Wrap(mkerr, "failed to create directory structure for file rename")
|
||||
}
|
||||
}
|
||||
|
||||
return os.Rename(cleanedFrom, cleanedTo)
|
||||
}
|
||||
|
||||
// Recursively iterates over a file or directory and sets the permissions on all of the
|
||||
// underlying files. Iterate over all of the files and directories. If it is a file just
|
||||
// go ahead and perform the chown operation. Otherwise dig deeper into the directory until
|
||||
// we've run out of directories to dig into.
|
||||
func (fs *Filesystem) Chown(path string) error {
|
||||
cleaned, err := fs.SafePath(path)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if fs.isTest {
|
||||
return nil
|
||||
}
|
||||
|
||||
uid := config.Get().System.User.Uid
|
||||
gid := config.Get().System.User.Gid
|
||||
|
||||
// Start by just chowning the initial path that we received.
|
||||
if err := os.Chown(cleaned, uid, gid); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// If this is not a directory we can now return from the function, there is nothing
|
||||
// left that we need to do.
|
||||
if st, _ := os.Stat(cleaned); !st.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If this was a directory, begin walking over its contents recursively and ensure that all
|
||||
// of the subfiles and directories get their permissions updated as well.
|
||||
return godirwalk.Walk(cleaned, &godirwalk.Options{
|
||||
Unsorted: true,
|
||||
Callback: func(p string, e *godirwalk.Dirent) error {
|
||||
// Do not attempt to chmod a symlink. Go's os.Chown function will affect the symlink
|
||||
// so if it points to a location outside the data directory the user would be able to
|
||||
// (un)intentionally modify that files permissions.
|
||||
if e.IsSymlink() {
|
||||
if e.IsDir() {
|
||||
return godirwalk.SkipThis
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Chown(p, uid, gid)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Begin looping up to 50 times to try and create a unique copy file name. This will take
|
||||
// an input of "file.txt" and generate "file copy.txt". If that name is already taken, it will
|
||||
// then try to write "file copy 2.txt" and so on, until reaching 50 loops. At that point we
|
||||
// won't waste anymore time, just use the current timestamp and make that copy.
|
||||
//
|
||||
// Could probably make this more efficient by checking if there are any files matching the copy
|
||||
// pattern, and trying to find the highest number and then incrementing it by one rather than
|
||||
// looping endlessly.
|
||||
func (fs *Filesystem) findCopySuffix(dir string, name string, extension string) (string, error) {
|
||||
var i int
|
||||
var suffix = " copy"
|
||||
|
||||
for i = 0; i < 51; i++ {
|
||||
if i > 0 {
|
||||
suffix = " copy " + strconv.Itoa(i)
|
||||
}
|
||||
|
||||
n := name + suffix + extension
|
||||
// If we stat the file and it does not exist that means we're good to create the copy. If it
|
||||
// does exist, we'll just continue to the next loop and try again.
|
||||
if _, err := fs.Stat(path.Join(dir, n)); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if i == 50 {
|
||||
suffix = "copy." + time.Now().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
return name + suffix + extension, nil
|
||||
}
|
||||
|
||||
// Copies a given file to the same location and appends a suffix to the file to indicate that
|
||||
// it has been copied.
|
||||
func (fs *Filesystem) Copy(p string) error {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s, err := os.Stat(cleaned)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
} else if s.IsDir() || !s.Mode().IsRegular() {
|
||||
// If this is a directory or not a regular file, just throw a not-exist error
|
||||
// since anything calling this function should understand what that means.
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// Check that copying this file wouldn't put the server over its limit.
|
||||
if err := fs.hasSpaceFor(s.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
base := filepath.Base(cleaned)
|
||||
relative := strings.TrimSuffix(strings.TrimPrefix(cleaned, fs.Path()), base)
|
||||
extension := filepath.Ext(base)
|
||||
name := strings.TrimSuffix(base, extension)
|
||||
|
||||
// Ensure that ".tar" is also counted as apart of the file extension.
|
||||
// There might be a better way to handle this for other double file extensions,
|
||||
// but this is a good workaround for now.
|
||||
if strings.HasSuffix(name, ".tar") {
|
||||
extension = ".tar" + extension
|
||||
name = strings.TrimSuffix(name, ".tar")
|
||||
}
|
||||
|
||||
source, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
n, err := fs.findCopySuffix(relative, name, extension)
|
||||
|
||||
return fs.Writefile(path.Join(relative, n), source)
|
||||
}
|
||||
|
||||
// Deletes a file or folder from the system. Prevents the user from accidentally
|
||||
// (or maliciously) removing their root server data directory.
|
||||
func (fs *Filesystem) Delete(p string) error {
|
||||
wg := sync.WaitGroup{}
|
||||
// This is one of the few (only?) places in the codebase where we're explicitly not using
|
||||
// the SafePath functionality when working with user provided input. If we did, you would
|
||||
// not be able to delete a file that is a symlink pointing to a location outside of the data
|
||||
// directory.
|
||||
//
|
||||
// We also want to avoid resolving a symlink that points _within_ the data directory and thus
|
||||
// deleting the actual source file for the symlink rather than the symlink itself. For these
|
||||
// purposes just resolve the actual file path using filepath.Join() and confirm that the path
|
||||
// exists within the data directory.
|
||||
resolved := fs.unsafeFilePath(p)
|
||||
if !fs.unsafeIsInDataDirectory(resolved) {
|
||||
return ErrBadPathResolution
|
||||
}
|
||||
|
||||
// Block any whoopsies.
|
||||
if resolved == fs.Path() {
|
||||
return errors.New("cannot delete root server directory")
|
||||
}
|
||||
|
||||
if st, err := os.Lstat(resolved); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
fs.error(err).Warn("error while attempting to stat file before deletion")
|
||||
}
|
||||
} else {
|
||||
if !st.IsDir() {
|
||||
fs.addDisk(-st.Size())
|
||||
} else {
|
||||
wg.Add(1)
|
||||
go func(wg *sync.WaitGroup, st os.FileInfo, resolved string) {
|
||||
defer wg.Done()
|
||||
if s, err := fs.DirectorySize(resolved); err == nil {
|
||||
fs.addDisk(-s)
|
||||
}
|
||||
}(&wg, st, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return os.RemoveAll(resolved)
|
||||
}
|
||||
|
||||
type fileOpener struct {
|
||||
busy uint
|
||||
}
|
||||
|
||||
// Attempts to open a given file up to "attempts" number of times, using a backoff. If the file
|
||||
// cannot be opened because of a "text file busy" error, we will attempt until the number of attempts
|
||||
// has been exhaused, at which point we will abort with an error.
|
||||
func (fo *fileOpener) open(path string, flags int, perm os.FileMode) (*os.File, error) {
|
||||
for {
|
||||
f, err := os.OpenFile(path, flags, perm)
|
||||
|
||||
// If there is an error because the text file is busy, go ahead and sleep for a few
|
||||
// hundred milliseconds and then try again up to three times before just returning the
|
||||
// error back to the caller.
|
||||
//
|
||||
// Based on code from: https://github.com/golang/go/issues/22220#issuecomment-336458122
|
||||
if err != nil && fo.busy < 3 && strings.Contains(err.Error(), "text file busy") {
|
||||
time.Sleep(100 * time.Millisecond << fo.busy)
|
||||
fo.busy++
|
||||
continue
|
||||
}
|
||||
|
||||
return f, err
|
||||
}
|
||||
}
|
||||
|
||||
// Lists the contents of a given directory and returns stat information about each
|
||||
// file and folder within it.
|
||||
func (fs *Filesystem) ListDirectory(p string) ([]*Stat, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := ioutil.ReadDir(cleaned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// You must initialize the output of this directory as a non-nil value otherwise
|
||||
// when it is marshaled into a JSON object you'll just get 'null' back, which will
|
||||
// break the panel badly.
|
||||
out := make([]*Stat, len(files))
|
||||
|
||||
// Iterate over all of the files and directories returned and perform an async process
|
||||
// to get the mime-type for them all.
|
||||
for i, file := range files {
|
||||
wg.Add(1)
|
||||
|
||||
go func(idx int, f os.FileInfo) {
|
||||
defer wg.Done()
|
||||
|
||||
var m *mimetype.MIME
|
||||
var d = "inode/directory"
|
||||
if !f.IsDir() {
|
||||
cleanedp := filepath.Join(cleaned, f.Name())
|
||||
if f.Mode()&os.ModeSymlink != 0 {
|
||||
cleanedp, _ = fs.SafePath(filepath.Join(cleaned, f.Name()))
|
||||
}
|
||||
|
||||
if cleanedp != "" {
|
||||
m, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
|
||||
} else {
|
||||
// Just pass this for an unknown type because the file could not safely be resolved within
|
||||
// the server data path.
|
||||
d = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
st := &Stat{
|
||||
Info: f,
|
||||
Mimetype: d,
|
||||
}
|
||||
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
|
||||
out[idx] = st
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sort the output alphabetically to begin with since we've run the output
|
||||
// through an asynchronous process and the order is gonna be very random.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Info.Name() == out[j].Info.Name() || out[i].Info.Name() > out[j].Info.Name() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Then, sort it so that directories are listed first in the output. Everything
|
||||
// will continue to be alphabetized at this point.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].Info.IsDir()
|
||||
})
|
||||
|
||||
return out, nil
|
||||
}
|
||||
784
server/filesystem/filesystem_test.go
Normal file
784
server/filesystem/filesystem_test.go
Normal file
@@ -0,0 +1,784 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
. "github.com/franela/goblin"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func NewFs() (*Filesystem, *rootFs) {
|
||||
config.Set(&config.Configuration{
|
||||
AuthenticationToken: "abc",
|
||||
System: config.SystemConfiguration{
|
||||
RootDirectory: "/server",
|
||||
DiskCheckInterval: 150,
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir, err := ioutil.TempDir(os.TempDir(), "pterodactyl")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// defer os.RemoveAll(tmpDir)
|
||||
|
||||
rfs := rootFs{root: tmpDir}
|
||||
|
||||
rfs.reset()
|
||||
|
||||
fs := New(filepath.Join(tmpDir, "/server"), 0)
|
||||
fs.isTest = true
|
||||
|
||||
return fs, &rfs
|
||||
}
|
||||
|
||||
type rootFs struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func (rfs *rootFs) CreateServerFile(p string, c string) error {
|
||||
f, err := os.Create(filepath.Join(rfs.root, "/server", p))
|
||||
|
||||
if err == nil {
|
||||
f.Write([]byte(c))
|
||||
f.Close()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (rfs *rootFs) StatServerFile(p string) (os.FileInfo, error) {
|
||||
return os.Stat(filepath.Join(rfs.root, "/server", p))
|
||||
}
|
||||
|
||||
func (rfs *rootFs) reset() {
|
||||
if err := os.RemoveAll(filepath.Join(rfs.root, "/server")); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Mkdir(filepath.Join(rfs.root, "/server"), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystem_Path(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Path", func() {
|
||||
g.It("returns the root path for the instance", func() {
|
||||
g.Assert(fs.Path()).Equal(filepath.Join(rfs.root, "/server"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_SafePath(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
prefix := filepath.Join(rfs.root, "/server")
|
||||
|
||||
g.Describe("SafePath", func() {
|
||||
g.It("returns a cleaned path to a given file", func() {
|
||||
p, err := fs.SafePath("test.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/test.txt")
|
||||
|
||||
p, err = fs.SafePath("/test.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/test.txt")
|
||||
|
||||
p, err = fs.SafePath("./test.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/test.txt")
|
||||
|
||||
p, err = fs.SafePath("/foo/../test.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/test.txt")
|
||||
|
||||
p, err = fs.SafePath("/foo/bar")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/foo/bar")
|
||||
})
|
||||
|
||||
g.It("handles root directory access", func() {
|
||||
p, err := fs.SafePath("/")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix)
|
||||
|
||||
p, err = fs.SafePath("")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix)
|
||||
})
|
||||
|
||||
g.It("removes trailing slashes from paths", func() {
|
||||
p, err := fs.SafePath("/foo/bar/")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/foo/bar")
|
||||
})
|
||||
|
||||
g.It("handles deeply nested directories that do not exist", func() {
|
||||
p, err := fs.SafePath("/foo/bar/baz/quaz/../../ducks/testing.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(p).Equal(prefix + "/foo/bar/ducks/testing.txt")
|
||||
})
|
||||
|
||||
g.It("blocks access to files outside the root directory", func() {
|
||||
p, err := fs.SafePath("../test.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
g.Assert(p).Equal("")
|
||||
|
||||
p, err = fs.SafePath("/../test.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
g.Assert(p).Equal("")
|
||||
|
||||
p, err = fs.SafePath("./foo/../../test.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
g.Assert(p).Equal("")
|
||||
|
||||
p, err = fs.SafePath("..")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
g.Assert(p).Equal("")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// We test against accessing files outside the root directory in the tests, however it
|
||||
// is still possible for someone to mess up and not properly use this safe path call. In
|
||||
// order to truly confirm this, we'll try to pass in a symlinked malicious file to all of
|
||||
// the calls and ensure they all fail with the same reason.
|
||||
func TestFilesystem_Blocks_Symlinks(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
if err := rfs.CreateServerFile("/../malicious.txt", "external content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Mkdir(filepath.Join(rfs.root, "/malicious_dir"), 0777); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "malicious.txt"), filepath.Join(rfs.root, "/server/symlinked.txt")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(filepath.Join(rfs.root, "/malicious_dir"), filepath.Join(rfs.root, "/server/external_dir")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
g.Describe("Readfile", func() {
|
||||
g.It("cannot read a file symlinked outside the root", func() {
|
||||
b := bytes.Buffer{}
|
||||
|
||||
err := fs.Readfile("symlinked.txt", &b)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Writefile", func() {
|
||||
g.It("cannot write to a file symlinked outside the root", func() {
|
||||
r := bytes.NewReader([]byte("testing"))
|
||||
|
||||
err := fs.Writefile("symlinked.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot write a file to a directory symlinked outside the root", func() {
|
||||
r := bytes.NewReader([]byte("testing"))
|
||||
|
||||
err := fs.Writefile("external_dir/foo.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("CreateDirectory", func() {
|
||||
g.It("cannot create a directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my_dir", "external_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot create a nested directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my/nested/dir", "external_dir/foo/bar")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot create a nested directory outside the root", func() {
|
||||
err := fs.CreateDirectory("my/nested/dir", "external_dir/server")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Rename", func() {
|
||||
g.It("cannot rename a file symlinked outside the directory root", func() {
|
||||
err := fs.Rename("symlinked.txt", "foo.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot rename a symlinked directory outside the root", func() {
|
||||
err := fs.Rename("external_dir", "foo")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot rename a file to a location outside the directory root", func() {
|
||||
rfs.CreateServerFile("my_file.txt", "internal content")
|
||||
|
||||
err := fs.Rename("my_file.txt", "external_dir/my_file.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Chown", func() {
|
||||
g.It("cannot chown a file symlinked outside the directory root", func() {
|
||||
err := fs.Chown("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot chown a directory symlinked outside the directory root", func() {
|
||||
err := fs.Chown("external_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Copy", func() {
|
||||
g.It("cannot copy a file symlinked outside the directory root", func() {
|
||||
err := fs.Copy("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
g.Describe("Delete", func() {
|
||||
g.It("deletes the symlinked file but leaves the source", func() {
|
||||
err := fs.Delete("symlinked.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = os.Stat(filepath.Join(rfs.root, "malicious.txt"))
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("symlinked.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
})
|
||||
})
|
||||
|
||||
rfs.reset()
|
||||
}
|
||||
|
||||
func TestFilesystem_Readfile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Readfile", func() {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
g.It("opens a file if it exists on the system", func() {
|
||||
err := rfs.CreateServerFile("test.txt", "testing")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("test.txt", buf)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(buf.String()).Equal("testing")
|
||||
})
|
||||
|
||||
g.It("returns an error if the file does not exist", func() {
|
||||
err := fs.Readfile("test.txt", buf)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns an error if the \"file\" is a directory", func() {
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "/server/test.txt"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("test.txt", buf)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrIsDirectory)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot open a file outside the root directory", func() {
|
||||
err := rfs.CreateServerFile("/../test.txt", "testing")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("/../test.txt", buf)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
buf.Truncate(0)
|
||||
fs.diskUsed = 0
|
||||
rfs.reset()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Writefile(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Open and WriteFile", func() {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// Test that a file can be written to the disk and that the disk space used as a result
|
||||
// is updated correctly in the end.
|
||||
g.It("can create a new file", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||
|
||||
err := fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("test.txt", buf)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(buf.String()).Equal("test file content")
|
||||
g.Assert(fs.diskUsed).Equal(r.Size())
|
||||
})
|
||||
|
||||
g.It("can create a new file inside a nested directory with leading slash", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("/some/nested/test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("/some/nested/test.txt", buf)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(buf.String()).Equal("test file content")
|
||||
})
|
||||
|
||||
g.It("can create a new file inside a nested directory without a trailing slash", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("some/../foo/bar/test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("foo/bar/test.txt", buf)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(buf.String()).Equal("test file content")
|
||||
})
|
||||
|
||||
g.It("cannot create a file outside the root directory", func() {
|
||||
r := bytes.NewReader([]byte("test file content"))
|
||||
|
||||
err := fs.Writefile("/some/../foo/../../test.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("cannot write a file that exceedes the disk limits", func() {
|
||||
fs.diskLimit = 1024
|
||||
|
||||
b := make([]byte, 1025)
|
||||
_, err := rand.Read(b)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(len(b)).Equal(1025)
|
||||
|
||||
r := bytes.NewReader(b)
|
||||
err = fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrNotEnoughDiskSpace)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("updates the total space used when a file is appended to", func() {
|
||||
fs.diskUsed = 100
|
||||
|
||||
b := make([]byte, 100)
|
||||
_, _ = rand.Read(b)
|
||||
|
||||
r := bytes.NewReader(b)
|
||||
err := fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(fs.diskUsed).Equal(int64(200))
|
||||
|
||||
// If we write less data than already exists, we should expect the total
|
||||
// disk used to be decremented.
|
||||
b = make([]byte, 50)
|
||||
_, _ = rand.Read(b)
|
||||
|
||||
r = bytes.NewReader(b)
|
||||
err = fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(fs.diskUsed).Equal(int64(150))
|
||||
})
|
||||
|
||||
g.It("truncates the file when writing new contents", func() {
|
||||
r := bytes.NewReader([]byte("original data"))
|
||||
err := fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
r = bytes.NewReader([]byte("new data"))
|
||||
err = fs.Writefile("test.txt", r)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Readfile("test.txt", buf)
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(buf.String()).Equal("new data")
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
buf.Truncate(0)
|
||||
rfs.reset()
|
||||
fs.diskUsed = 0
|
||||
fs.diskLimit = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_CreateDirectory(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("CreateDirectory", func() {
|
||||
g.It("should create missing directories automatically", func() {
|
||||
err := fs.CreateDirectory("test", "foo/bar/baz")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := rfs.StatServerFile("foo/bar/baz/test")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Name()).Equal("test")
|
||||
})
|
||||
|
||||
g.It("should work with leading and trailing slashes", func() {
|
||||
err := fs.CreateDirectory("test", "/foozie/barzie/bazzy/")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := rfs.StatServerFile("foozie/barzie/bazzy/test")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
g.Assert(st.Name()).Equal("test")
|
||||
})
|
||||
|
||||
g.It("should not allow the creation of directories outside the root", func() {
|
||||
err := fs.CreateDirectory("test", "e/../../something")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should not increment the disk usage", func() {
|
||||
err := fs.CreateDirectory("test", "/")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
rfs.reset()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Rename(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Rename", func() {
|
||||
g.BeforeEach(func() {
|
||||
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
g.It("returns an error if the target already exists", func() {
|
||||
err := rfs.CreateServerFile("target.txt", "taget content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Rename("source.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns an error if the final destination is the root directory", func() {
|
||||
err := fs.Rename("source.txt", "/")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns an error if the source destination is the root directory", func() {
|
||||
err := fs.Rename("source.txt", "/")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("does not allow renaming to a location outside the root", func() {
|
||||
err := fs.Rename("source.txt", "../target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("does not allow renaming from a location outside the root", func() {
|
||||
err := rfs.CreateServerFile("/../ext-source.txt", "taget content")
|
||||
|
||||
err = fs.Rename("/../ext-source.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("allows a file to be renamed", func() {
|
||||
err := fs.Rename("source.txt", "target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
|
||||
st, err := rfs.StatServerFile("target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("target.txt")
|
||||
g.Assert(st.Size()).IsNotZero()
|
||||
})
|
||||
|
||||
g.It("allows a folder to be renamed", func() {
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "/server/source_dir"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Rename("source_dir", "target_dir")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("source_dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
|
||||
st, err := rfs.StatServerFile("target_dir")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.IsDir()).IsTrue()
|
||||
})
|
||||
|
||||
g.It("returns an error if the source does not exist", func() {
|
||||
err := fs.Rename("missing.txt", "target.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("creates directories if they are missing", func() {
|
||||
err := fs.Rename("source.txt", "nested/folder/target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := rfs.StatServerFile("nested/folder/target.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("target.txt")
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
rfs.reset()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Copy(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Copy", func() {
|
||||
g.BeforeEach(func() {
|
||||
if err := rfs.CreateServerFile("source.txt", "text content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.diskUsed = int64(utf8.RuneCountInString("test content"))
|
||||
})
|
||||
|
||||
g.It("should return an error if the source does not exist", func() {
|
||||
err := fs.Copy("foo.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if the source is outside the root", func() {
|
||||
err := rfs.CreateServerFile("/../ext-source.txt", "text content")
|
||||
|
||||
err = fs.Copy("../ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if the source directory is outside the root", func() {
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "/nested/in/dir"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = rfs.CreateServerFile("/../nested/in/dir/ext-source.txt", "external content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("../nested/in/dir/ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
|
||||
err = fs.Copy("nested/in/../../../nested/in/dir/ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if the source is a directory", func() {
|
||||
err := os.Mkdir(filepath.Join(rfs.root, "/server/dir"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("dir")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should return an error if there is not space to copy the file", func() {
|
||||
fs.diskLimit = 2
|
||||
|
||||
err := fs.Copy("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrNotEnoughDiskSpace)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("should create a copy of the file and increment the disk used", func() {
|
||||
err := fs.Copy("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("source copy.txt")
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.It("should create a copy of the file with a suffix if a copy already exists", func() {
|
||||
err := fs.Copy("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
r := []string{"source.txt", "source copy.txt", "source copy 1.txt"}
|
||||
|
||||
for _, name := range r {
|
||||
_, err = rfs.StatServerFile(name)
|
||||
g.Assert(err).IsNil()
|
||||
}
|
||||
|
||||
g.Assert(fs.diskUsed).Equal(int64(utf8.RuneCountInString("test content")) * 3)
|
||||
})
|
||||
|
||||
g.It("should create a copy inside of a directory", func() {
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "/server/nested/in/dir"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = rfs.CreateServerFile("nested/in/dir/source.txt", "test content")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
err = fs.Copy("nested/in/dir/source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("nested/in/dir/source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("nested/in/dir/source copy.txt")
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
rfs.reset()
|
||||
fs.diskUsed = 0
|
||||
fs.diskLimit = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystem_Delete(t *testing.T) {
|
||||
g := Goblin(t)
|
||||
fs, rfs := NewFs()
|
||||
|
||||
g.Describe("Delete", func() {
|
||||
g.BeforeEach(func() {
|
||||
if err := rfs.CreateServerFile("source.txt", "test content"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fs.diskUsed = int64(utf8.RuneCountInString("test content"))
|
||||
})
|
||||
|
||||
g.It("does not delete files outside the root directory", func() {
|
||||
err := rfs.CreateServerFile("/../ext-source.txt", "external content")
|
||||
|
||||
err = fs.Delete("../ext-source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, ErrBadPathResolution)).IsTrue()
|
||||
})
|
||||
|
||||
g.It("does not allow the deletion of the root directory", func() {
|
||||
err := fs.Delete("/")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(err.Error()).Equal("cannot delete root server directory")
|
||||
})
|
||||
|
||||
g.It("does not return an error if the target does not exist", func() {
|
||||
err := fs.Delete("missing.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
st, err := rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(st.Name()).Equal("source.txt")
|
||||
})
|
||||
|
||||
g.It("deletes files and subtracts their size from the disk usage", func() {
|
||||
err := fs.Delete("source.txt")
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
_, err = rfs.StatServerFile("source.txt")
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
|
||||
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||
})
|
||||
|
||||
g.It("deletes all items inside a directory if the directory is deleted", func() {
|
||||
sources := []string{
|
||||
"foo/source.txt",
|
||||
"foo/bar/source.txt",
|
||||
"foo/bar/baz/source.txt",
|
||||
}
|
||||
|
||||
err := os.MkdirAll(filepath.Join(rfs.root, "/server/foo/bar/baz"), 0755)
|
||||
g.Assert(err).IsNil()
|
||||
|
||||
for _, s := range sources {
|
||||
err = rfs.CreateServerFile(s, "test content")
|
||||
g.Assert(err).IsNil()
|
||||
}
|
||||
|
||||
fs.diskUsed = int64(utf8.RuneCountInString("test content") * 3)
|
||||
|
||||
err = fs.Delete("foo")
|
||||
g.Assert(err).IsNil()
|
||||
g.Assert(fs.diskUsed).Equal(int64(0))
|
||||
|
||||
for _, s := range sources {
|
||||
_, err = rfs.StatServerFile(s)
|
||||
g.Assert(err).IsNotNil()
|
||||
g.Assert(errors.Is(err, os.ErrNotExist)).IsTrue()
|
||||
}
|
||||
})
|
||||
|
||||
g.AfterEach(func() {
|
||||
rfs.reset()
|
||||
fs.diskUsed = 0
|
||||
fs.diskLimit = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
141
server/filesystem/path.go
Normal file
141
server/filesystem/path.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Normalizes a directory being passed in to ensure the user is not able to escape
|
||||
// from their data directory. After normalization if the directory is still within their home
|
||||
// path it is returned. If they managed to "escape" an error will be returned.
|
||||
//
|
||||
// This logic is actually copied over from the SFTP server code. Ideally that eventually
|
||||
// either gets ported into this application, or is able to make use of this package.
|
||||
func (fs *Filesystem) SafePath(p string) (string, error) {
|
||||
var nonExistentPathResolution string
|
||||
|
||||
// Start with a cleaned up path before checking the more complex bits.
|
||||
r := fs.unsafeFilePath(p)
|
||||
|
||||
// At the same time, evaluate the symlink status and determine where this file or folder
|
||||
// is truly pointing to.
|
||||
p, err := filepath.EvalSymlinks(r)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
} else if os.IsNotExist(err) {
|
||||
// The requested directory doesn't exist, so at this point we need to iterate up the
|
||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||
parts := strings.Split(filepath.Dir(r), "/")
|
||||
|
||||
var try string
|
||||
// Range over all of the path parts and form directory pathings from the end
|
||||
// moving up until we have a valid resolution or we run out of paths to try.
|
||||
for k := range parts {
|
||||
try = strings.Join(parts[:(len(parts)-k)], "/")
|
||||
|
||||
if !fs.unsafeIsInDataDirectory(try) {
|
||||
break
|
||||
}
|
||||
|
||||
t, err := filepath.EvalSymlinks(try)
|
||||
if err == nil {
|
||||
nonExistentPathResolution = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the new path doesn't start with their root directory there is clearly an escape
|
||||
// attempt going on, and we should NOT resolve this path for them.
|
||||
if nonExistentPathResolution != "" {
|
||||
if !fs.unsafeIsInDataDirectory(nonExistentPathResolution) {
|
||||
return "", ErrBadPathResolution
|
||||
}
|
||||
|
||||
// If the nonExistentPathResolution variable is not empty then the initial path requested
|
||||
// did not exist and we looped through the pathway until we found a match. At this point
|
||||
// we've confirmed the first matched pathway exists in the root server directory, so we
|
||||
// can go ahead and just return the path that was requested initially.
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// If the requested directory from EvalSymlinks begins with the server root directory go
|
||||
// ahead and return it. If not we'll return an error which will block any further action
|
||||
// on the file.
|
||||
if fs.unsafeIsInDataDirectory(p) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
return "", ErrBadPathResolution
|
||||
}
|
||||
|
||||
// Generate a path to the file by cleaning it up and appending the root server path to it. This
|
||||
// DOES NOT guarantee that the file resolves within the server data directory. You'll want to use
|
||||
// the fs.unsafeIsInDataDirectory(p) function to confirm.
|
||||
func (fs *Filesystem) unsafeFilePath(p string) string {
|
||||
// Calling filepath.Clean on the joined directory will resolve it to the absolute path,
|
||||
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
|
||||
//
|
||||
// This will also trim the existing root path off the beginning of the path passed to
|
||||
// the function since that can get a bit messy.
|
||||
return filepath.Clean(filepath.Join(fs.Path(), strings.TrimPrefix(p, fs.Path())))
|
||||
}
|
||||
|
||||
// Check that that path string starts with the server data directory path. This function DOES NOT
|
||||
// validate that the rest of the path does not end up resolving out of this directory, or that the
|
||||
// targeted file or folder is not a symlink doing the same thing.
|
||||
func (fs *Filesystem) unsafeIsInDataDirectory(p string) bool {
|
||||
return strings.HasPrefix(strings.TrimSuffix(p, "/")+"/", strings.TrimSuffix(fs.Path(), "/")+"/")
|
||||
}
|
||||
|
||||
// Executes the fs.SafePath function in parallel against an array of paths. If any of the calls
|
||||
// fails an error will be returned.
|
||||
func (fs *Filesystem) ParallelSafePath(paths []string) ([]string, error) {
|
||||
var cleaned []string
|
||||
|
||||
// Simple locker function to avoid racy appends to the array of cleaned paths.
|
||||
var m = new(sync.Mutex)
|
||||
var push = func(c string) {
|
||||
m.Lock()
|
||||
cleaned = append(cleaned, c)
|
||||
m.Unlock()
|
||||
}
|
||||
|
||||
// Create an error group that we can use to run processes in parallel while retaining
|
||||
// the ability to cancel the entire process immediately should any of it fail.
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
// Iterate over all of the paths and generate a cleaned path, if there is an error for any
|
||||
// of the files, abort the process.
|
||||
for _, p := range paths {
|
||||
// Create copy so we can use it within the goroutine correctly.
|
||||
pi := p
|
||||
|
||||
// Recursively call this function to continue digging through the directory tree within
|
||||
// a separate goroutine. If the context is canceled abort this process.
|
||||
g.Go(func() error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// If the callback returns true, go ahead and keep walking deeper. This allows
|
||||
// us to programmatically continue deeper into directories, or stop digging
|
||||
// if that pathway knows it needs nothing else.
|
||||
if c, err := fs.SafePath(pi); err != nil {
|
||||
return err
|
||||
} else {
|
||||
push(c)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Block until all of the routines finish and have returned a value.
|
||||
return cleaned, g.Wait()
|
||||
}
|
||||
74
server/filesystem/stat.go
Normal file
74
server/filesystem/stat.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Stat struct {
|
||||
Info os.FileInfo
|
||||
Mimetype string
|
||||
}
|
||||
|
||||
func (s *Stat) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
Modified string `json:"modified"`
|
||||
Mode string `json:"mode"`
|
||||
Size int64 `json:"size"`
|
||||
Directory bool `json:"directory"`
|
||||
File bool `json:"file"`
|
||||
Symlink bool `json:"symlink"`
|
||||
Mime string `json:"mime"`
|
||||
}{
|
||||
Name: s.Info.Name(),
|
||||
Created: s.CTime().Format(time.RFC3339),
|
||||
Modified: s.Info.ModTime().Format(time.RFC3339),
|
||||
Mode: s.Info.Mode().String(),
|
||||
Size: s.Info.Size(),
|
||||
Directory: s.Info.IsDir(),
|
||||
File: !s.Info.IsDir(),
|
||||
Symlink: s.Info.Mode().Perm()&os.ModeSymlink != 0,
|
||||
Mime: s.Mimetype,
|
||||
})
|
||||
}
|
||||
|
||||
// Stats a file or folder and returns the base stat object from go along with the
|
||||
// MIME data that can be used for editing files.
|
||||
func (fs *Filesystem) Stat(p string) (*Stat, error) {
|
||||
cleaned, err := fs.SafePath(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fs.unsafeStat(cleaned)
|
||||
}
|
||||
|
||||
func (fs *Filesystem) unsafeStat(p string) (*Stat, error) {
|
||||
s, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m *mimetype.MIME
|
||||
if !s.IsDir() {
|
||||
m, err = mimetype.DetectFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
st := &Stat{
|
||||
Info: s,
|
||||
Mimetype: "inode/directory",
|
||||
}
|
||||
|
||||
if m != nil {
|
||||
st.Mimetype = m.String()
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -395,7 +395,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
|
||||
AttachStdin: true,
|
||||
OpenStdin: true,
|
||||
Tty: true,
|
||||
Cmd: []string{ip.Script.Entrypoint, "./mnt/install/install.sh"},
|
||||
Cmd: []string{ip.Script.Entrypoint, "/mnt/install/install.sh"},
|
||||
Image: ip.Script.ContainerImage,
|
||||
Env: ip.Server.GetEnvironmentVariables(),
|
||||
Labels: map[string]string{
|
||||
@@ -409,7 +409,7 @@ func (ip *InstallationProcess) Execute() (string, error) {
|
||||
Mounts: []mount.Mount{
|
||||
{
|
||||
Target: "/mnt/server",
|
||||
Source: ip.Server.Filesystem.Path(),
|
||||
Source: ip.Server.Filesystem().Path(),
|
||||
Type: mount.TypeBind,
|
||||
ReadOnly: false,
|
||||
},
|
||||
|
||||
@@ -5,65 +5,104 @@ import (
|
||||
"github.com/apex/log"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/api"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/events"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Adds all of the internal event listeners we want to use for a server.
|
||||
var dockerEvents = []string{
|
||||
environment.DockerImagePullStatus,
|
||||
environment.DockerImagePullStarted,
|
||||
environment.DockerImagePullCompleted,
|
||||
}
|
||||
|
||||
// Adds all of the internal event listeners we want to use for a server. These listeners can only be
|
||||
// removed by deleting the server as they should last for the duration of the process' lifetime.
|
||||
func (s *Server) StartEventListeners() {
|
||||
console := make(chan events.Event)
|
||||
state := make(chan events.Event)
|
||||
stats := make(chan events.Event)
|
||||
console := func(e events.Event) {
|
||||
t := s.Throttler()
|
||||
err := t.Increment(func() {
|
||||
s.PublishConsoleOutputFromDaemon("Your server is outputting too much data and is being throttled.")
|
||||
})
|
||||
|
||||
go func(console chan events.Event) {
|
||||
for data := range console {
|
||||
// Immediately emit this event back over the server event stream since it is
|
||||
// being called from the environment event stream and things probably aren't
|
||||
// listening to that event.
|
||||
s.Events().Publish(ConsoleOutputEvent, data.Data)
|
||||
// An error is only returned if the server has breached the thresholds set.
|
||||
if err != nil {
|
||||
// If the process is already stopping, just let it continue with that action rather than attempting
|
||||
// to terminate again.
|
||||
if s.GetState() != environment.ProcessStoppingState {
|
||||
s.SetState(environment.ProcessStoppingState)
|
||||
go func() {
|
||||
s.Log().Warn("stopping server instance, violating throttle limits")
|
||||
s.PublishConsoleOutputFromDaemon("Your server is being stopped for outputting too much data in a short period of time.")
|
||||
// Completely skip over server power actions and terminate the running instance. This gives the
|
||||
// server 15 seconds to finish stopping gracefully before it is forcefully terminated.
|
||||
if err := s.Environment.WaitForStop(config.Get().Throttles.StopGracePeriod, true); err != nil {
|
||||
// If there is an error set the process back to running so that this throttler is called
|
||||
// again and hopefully kills the server.
|
||||
if s.GetState() != environment.ProcessOfflineState {
|
||||
s.SetState(environment.ProcessRunningState)
|
||||
}
|
||||
|
||||
// Also pass the data along to the console output channel.
|
||||
s.onConsoleOutput(data.Data)
|
||||
}
|
||||
|
||||
s.Log().Fatal("unexpected end-of-range for server console channel")
|
||||
}(console)
|
||||
|
||||
go func(state chan events.Event) {
|
||||
for data := range state {
|
||||
s.SetState(data.Data)
|
||||
}
|
||||
|
||||
s.Log().Fatal("unexpected end-of-range for server state channel")
|
||||
}(state)
|
||||
|
||||
go func(stats chan events.Event) {
|
||||
for data := range stats {
|
||||
st := new(environment.Stats)
|
||||
if err := json.Unmarshal([]byte(data.Data), st); err != nil {
|
||||
s.Log().WithField("error", errors.WithStack(err)).Warn("failed to unmarshal server environment stats")
|
||||
continue
|
||||
s.Log().WithField("error", errors.WithStack(err)).Error("failed to terminate environment after triggering throttle")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Update the server resource tracking object with the resources we got here.
|
||||
s.resources.mu.Lock()
|
||||
s.resources.Stats = *st
|
||||
s.resources.mu.Unlock()
|
||||
|
||||
s.Filesystem.HasSpaceAvailable(true)
|
||||
|
||||
s.emitProcUsage()
|
||||
}
|
||||
|
||||
s.Log().Fatal("unexpected end-of-range for server stats channel")
|
||||
}(stats)
|
||||
// If we are not throttled, go ahead and output the data.
|
||||
if !t.Throttled() {
|
||||
s.Events().Publish(ConsoleOutputEvent, e.Data)
|
||||
}
|
||||
|
||||
// Also pass the data along to the console output channel.
|
||||
s.onConsoleOutput(e.Data)
|
||||
}
|
||||
|
||||
state := func(e events.Event) {
|
||||
// Reset the throttler when the process is started.
|
||||
if e.Data == environment.ProcessStartingState {
|
||||
s.Throttler().Reset()
|
||||
}
|
||||
|
||||
s.SetState(e.Data)
|
||||
}
|
||||
|
||||
stats := func(e events.Event) {
|
||||
st := new(environment.Stats)
|
||||
if err := json.Unmarshal([]byte(e.Data), st); err != nil {
|
||||
s.Log().WithField("error", errors.WithStack(err)).Warn("failed to unmarshal server environment stats")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the server resource tracking object with the resources we got here.
|
||||
s.resources.mu.Lock()
|
||||
s.resources.Stats = *st
|
||||
s.resources.mu.Unlock()
|
||||
|
||||
s.Filesystem().HasSpaceAvailable(true)
|
||||
|
||||
s.emitProcUsage()
|
||||
}
|
||||
|
||||
docker := func(e events.Event) {
|
||||
if e.Topic == environment.DockerImagePullStatus {
|
||||
s.Events().Publish(InstallOutputEvent, e.Data)
|
||||
} else if e.Topic == environment.DockerImagePullStarted {
|
||||
s.PublishConsoleOutputFromDaemon("Pulling Docker container image, this could take a few minutes to complete...")
|
||||
} else {
|
||||
s.PublishConsoleOutputFromDaemon("Finished pulling Docker container image")
|
||||
}
|
||||
}
|
||||
|
||||
s.Log().Info("registering event listeners: console, state, resources...")
|
||||
s.Environment.Events().Subscribe([]string{environment.ConsoleOutputEvent}, console)
|
||||
s.Environment.Events().Subscribe([]string{environment.StateChangeEvent}, state)
|
||||
s.Environment.Events().Subscribe([]string{environment.ResourceEvent}, stats)
|
||||
s.Environment.Events().On(environment.ConsoleOutputEvent, &console)
|
||||
s.Environment.Events().On(environment.StateChangeEvent, &state)
|
||||
s.Environment.Events().On(environment.ResourceEvent, &stats)
|
||||
for _, evt := range dockerEvents {
|
||||
s.Environment.Events().On(evt, &docker)
|
||||
}
|
||||
}
|
||||
|
||||
var stripAnsiRegex = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"github.com/gammazero/workerpool"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/api"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/environment/docker"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
@@ -37,12 +40,6 @@ func LoadDirectory() error {
|
||||
return errors.New(rerr.String())
|
||||
}
|
||||
|
||||
log.Debug("retrieving cached server states from disk")
|
||||
states, err := getServerStates()
|
||||
if err != nil {
|
||||
log.WithField("error", errors.WithStack(err)).Error("failed to retrieve locally cached server states from disk, assuming all servers in offline state")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
log.WithField("total_configs", len(configs)).Info("processing servers returned by the API")
|
||||
|
||||
@@ -59,11 +56,6 @@ func LoadDirectory() error {
|
||||
return
|
||||
}
|
||||
|
||||
if state, exists := states[s.Id()]; exists {
|
||||
s.Log().WithField("state", state).Debug("found existing server state in cache file; re-instantiating server state")
|
||||
s.SetState(state)
|
||||
}
|
||||
|
||||
servers.Add(s)
|
||||
})
|
||||
}
|
||||
@@ -97,8 +89,11 @@ func FromConfiguration(data *api.ServerConfigurationResponse) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.resources = ResourceUsage{}
|
||||
defaults.Set(&s.resources)
|
||||
|
||||
s.Archiver = Archiver{Server: s}
|
||||
s.Filesystem = Filesystem{Server: s}
|
||||
s.fs = filesystem.New(filepath.Join(config.Get().System.Data, s.Id()), s.DiskSpace())
|
||||
|
||||
// Right now we only support a Docker based environment, so I'm going to hard code
|
||||
// this logic in. When we're ready to support other environment we'll need to make
|
||||
@@ -119,6 +114,7 @@ func FromConfiguration(data *api.ServerConfigurationResponse) (*Server, error) {
|
||||
} else {
|
||||
s.Environment = env
|
||||
s.StartEventListeners()
|
||||
s.Throttler().StartTimer()
|
||||
}
|
||||
|
||||
// Forces the configuration to be synced with the panel.
|
||||
@@ -127,8 +123,8 @@ func FromConfiguration(data *api.ServerConfigurationResponse) (*Server, error) {
|
||||
}
|
||||
|
||||
// If the server's data directory exists, force disk usage calculation.
|
||||
if _, err := os.Stat(s.Filesystem.Path()); err == nil {
|
||||
s.Filesystem.HasSpaceAvailable(true)
|
||||
if _, err := os.Stat(s.Filesystem().Path()); err == nil {
|
||||
s.Filesystem().HasSpaceAvailable(true)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Server) Mounts() []environment.Mount {
|
||||
m = append(m, environment.Mount{
|
||||
Default: true,
|
||||
Target: "/home/container",
|
||||
Source: s.Filesystem.Path(),
|
||||
Source: s.Filesystem().Path(),
|
||||
ReadOnly: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterodactyl/wings/config"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"os"
|
||||
"time"
|
||||
@@ -38,6 +39,21 @@ func (pa PowerAction) IsStart() bool {
|
||||
return pa == PowerActionStart || pa == PowerActionRestart
|
||||
}
|
||||
|
||||
// Check if there is currently a power action being processed for the server.
|
||||
func (s *Server) ExecutingPowerAction() bool {
|
||||
if s.powerLock == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ok := s.powerLock.TryAcquire(1)
|
||||
if ok {
|
||||
s.powerLock.Release(1)
|
||||
}
|
||||
|
||||
// Remember, if we acquired a lock it means nothing was running.
|
||||
return !ok
|
||||
}
|
||||
|
||||
// Helper function that can receive a power action and then process the actions that need
|
||||
// to occur for it. This guards against someone calling Start() twice at the same time, or
|
||||
// trying to restart while another restart process is currently running.
|
||||
@@ -149,11 +165,11 @@ func (s *Server) onBeforeStart() error {
|
||||
// If a server has unlimited disk space, we don't care enough to block the startup to check remaining.
|
||||
// However, we should trigger a size anyway, as it'd be good to kick it off for other processes.
|
||||
if s.DiskSpace() <= 0 {
|
||||
s.Filesystem.HasSpaceAvailable(true)
|
||||
s.Filesystem().HasSpaceAvailable(true)
|
||||
} else {
|
||||
s.PublishConsoleOutputFromDaemon("Checking server disk space usage, this could take a few seconds...")
|
||||
if !s.Filesystem.HasSpaceAvailable(false) {
|
||||
return errors.New("cannot start server, not enough disk space available")
|
||||
if !s.Filesystem().HasSpaceAvailable(false) {
|
||||
return filesystem.ErrNotEnoughDiskSpace
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +184,7 @@ func (s *Server) onBeforeStart() error {
|
||||
if config.Get().System.CheckPermissionsOnBoot {
|
||||
s.PublishConsoleOutputFromDaemon("Ensuring file permissions are set correctly, this could take a few seconds...")
|
||||
// Ensure all of the server file permissions are set correctly before booting the process.
|
||||
if err := s.Filesystem.Chown("/"); err != nil {
|
||||
if err := s.Filesystem().Chown("/"); err != nil {
|
||||
return errors.Wrap(err, "failed to chown root server directory during pre-boot process")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"sync"
|
||||
)
|
||||
@@ -37,14 +36,10 @@ func (s *Server) Proc() *ResourceUsage {
|
||||
|
||||
func (s *Server) emitProcUsage() {
|
||||
s.resources.mu.RLock()
|
||||
defer s.resources.mu.RUnlock()
|
||||
|
||||
b, err := json.Marshal(s.resources)
|
||||
if err == nil {
|
||||
s.Events().Publish(StatsEvent, string(b))
|
||||
if err := s.Events().PublishJson(StatsEvent, s.resources); err != nil {
|
||||
s.Log().WithField("error", err).Warn("error while emitting server resource usage to listeners")
|
||||
}
|
||||
|
||||
// TODO: This might be a good place to add a debug log if stats are not sending.
|
||||
s.resources.mu.RUnlock()
|
||||
}
|
||||
|
||||
// Returns the servers current state.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/pterodactyl/wings/environment"
|
||||
"github.com/pterodactyl/wings/environment/docker"
|
||||
"github.com/pterodactyl/wings/events"
|
||||
"github.com/pterodactyl/wings/server/filesystem"
|
||||
"golang.org/x/sync/semaphore"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -22,7 +23,7 @@ type Server struct {
|
||||
sync.RWMutex
|
||||
emitterLock sync.Mutex
|
||||
powerLock *semaphore.Weighted
|
||||
throttleLock sync.RWMutex
|
||||
throttleLock sync.Mutex
|
||||
|
||||
// Maintains the configuration for the server. This is the data that gets returned by the Panel
|
||||
// such as build settings and container images.
|
||||
@@ -34,7 +35,8 @@ type Server struct {
|
||||
resources ResourceUsage
|
||||
Archiver Archiver `json:"-"`
|
||||
Environment environment.ProcessEnvironment `json:"-"`
|
||||
Filesystem Filesystem `json:"-"`
|
||||
|
||||
fs *filesystem.Filesystem
|
||||
|
||||
// Events emitted by the server instance.
|
||||
emitter *events.EventBus
|
||||
@@ -52,6 +54,10 @@ type Server struct {
|
||||
|
||||
// The console throttler instance used to control outputs.
|
||||
throttler *ConsoleThrottler
|
||||
|
||||
// Tracks open websocket connections for the server.
|
||||
wsBag *WebsocketBag
|
||||
wsBagLocker sync.Mutex
|
||||
}
|
||||
|
||||
type InstallerDetails struct {
|
||||
@@ -133,10 +139,15 @@ func (s *Server) SyncWithConfiguration(cfg *api.ServerConfigurationResponse) err
|
||||
s.procConfig = cfg.ProcessConfiguration
|
||||
s.Unlock()
|
||||
|
||||
// Update the disk space limits for the server whenever the configuration
|
||||
// for it changes.
|
||||
s.fs.SetDiskLimit(s.DiskSpace())
|
||||
|
||||
// If this is a Docker environment we need to sync the stop configuration with it so that
|
||||
// the process isn't just terminated when a user requests it be stopped.
|
||||
if e, ok := s.Environment.(*docker.Environment); ok {
|
||||
s.Log().Debug("syncing stop configuration with configured docker environment")
|
||||
e.SetImage(s.Config().Container.Image)
|
||||
e.SetStopConfiguration(&cfg.ProcessConfiguration.Stop)
|
||||
}
|
||||
|
||||
@@ -160,7 +171,7 @@ func (s *Server) IsBootable() bool {
|
||||
// for the server is setup, and that all of the necessary files are created.
|
||||
func (s *Server) CreateEnvironment() error {
|
||||
// Ensure the data directory exists before getting too far through this process.
|
||||
if err := s.Filesystem.EnsureDataDirectory(); err != nil {
|
||||
if err := s.EnsureDataDirectoryExists(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
var stateMutex sync.Mutex
|
||||
|
||||
// Returns the state of the servers.
|
||||
func getServerStates() (map[string]string, error) {
|
||||
func CachedServerStates() (map[string]string, error) {
|
||||
// Request a lock after we check if the file exists.
|
||||
stateMutex.Lock()
|
||||
defer stateMutex.Unlock()
|
||||
@@ -78,8 +78,8 @@ func (s *Server) SetState(state string) error {
|
||||
|
||||
// Emit the event to any listeners that are currently registered.
|
||||
if prevState != state {
|
||||
s.Log().WithField("status", s.Proc().State).Debug("saw server status change event")
|
||||
s.Events().Publish(StatusEvent, s.Proc().State)
|
||||
s.Log().WithField("status", s.Proc().getInternalState()).Debug("saw server status change event")
|
||||
s.Events().Publish(StatusEvent, s.Proc().getInternalState())
|
||||
}
|
||||
|
||||
// Persist this change to the disk immediately so that should the Daemon be stopped or
|
||||
|
||||
61
server/websockets.go
Normal file
61
server/websockets.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WebsocketBag struct {
|
||||
mu sync.Mutex
|
||||
conns map[uuid.UUID]*context.CancelFunc
|
||||
}
|
||||
|
||||
// Returns the websocket bag which contains all of the currently open websocket connections
|
||||
// for the server instance.
|
||||
func (s *Server) Websockets() *WebsocketBag {
|
||||
s.wsBagLocker.Lock()
|
||||
defer s.wsBagLocker.Unlock()
|
||||
|
||||
if s.wsBag == nil {
|
||||
s.wsBag = &WebsocketBag{}
|
||||
}
|
||||
|
||||
return s.wsBag
|
||||
}
|
||||
|
||||
// Adds a new websocket connection to the stack.
|
||||
func (w *WebsocketBag) Push(u uuid.UUID, cancel *context.CancelFunc) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.conns == nil {
|
||||
w.conns = make(map[uuid.UUID]*context.CancelFunc)
|
||||
}
|
||||
|
||||
w.conns[u] = cancel
|
||||
}
|
||||
|
||||
// Removes a connection from the stack.
|
||||
func (w *WebsocketBag) Remove(u uuid.UUID) {
|
||||
w.mu.Lock()
|
||||
delete(w.conns, u)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Cancels all of the stored cancel functions which has the effect of disconnecting
|
||||
// every listening websocket for the server.
|
||||
func (w *WebsocketBag) CancelAll() {
|
||||
w.mu.Lock()
|
||||
w.mu.Unlock()
|
||||
|
||||
if w.conns != nil {
|
||||
for _, cancel := range w.conns {
|
||||
c := *cancel
|
||||
c()
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the connections.
|
||||
w.conns = make(map[uuid.UUID]*context.CancelFunc)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func validatePath(fs FileSystem, p string) (string, error) {
|
||||
return "", noMatchingServerError
|
||||
}
|
||||
|
||||
return s.Filesystem.SafePath(p)
|
||||
return s.Filesystem().SafePath(p)
|
||||
}
|
||||
|
||||
func validateDiskSpace(fs FileSystem) bool {
|
||||
@@ -63,7 +63,7 @@ func validateDiskSpace(fs FileSystem) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return s.Filesystem.HasSpaceAvailable(true)
|
||||
return s.Filesystem().HasSpaceAvailable(true)
|
||||
}
|
||||
|
||||
// Validates a set of credentials for a SFTP login against Pterodactyl Panel and returns
|
||||
|
||||
20
system/bool.go
Normal file
20
system/bool.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package system
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
type AtomicBool struct {
|
||||
flag uint32
|
||||
}
|
||||
|
||||
func (ab *AtomicBool) Set(v bool) {
|
||||
i := 0
|
||||
if v {
|
||||
i = 1
|
||||
}
|
||||
|
||||
atomic.StoreUint32(&ab.flag, uint32(i))
|
||||
}
|
||||
|
||||
func (ab *AtomicBool) Get() bool {
|
||||
return atomic.LoadUint32(&ab.flag) == 1
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{{.LogDirectory}}/wings.log {
|
||||
size 10M
|
||||
compress
|
||||
delaycompress
|
||||
dateext
|
||||
maxage 7
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 {{.User.Uid}} {{.User.Gid}}
|
||||
postrotate
|
||||
killall -SIGHUP wings
|
||||
endscript
|
||||
}
|
||||
Reference in New Issue
Block a user