wings/server/filesystem/filesystem.go
DaneEveritt b00d328107
attempt to update vhd on-the-fly
This is not currently working correctly — when setting the limit to 0 all existing files are destroyed. When setting it back to a non-zero value the old files are returned from the allocated disk.

This at least starts getting the code pathways in place to handle it and introduces some additional lock safety on the VHD management code.
2022-10-29 14:06:29 -07:00

563 lines
17 KiB
Go

package filesystem
import (
"bufio"
"io"
"io/ioutil"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"emperror.dev/errors"
"github.com/gabriel-vasile/mimetype"
"github.com/karrick/godirwalk"
ignore "github.com/sabhiram/go-gitignore"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/internal/vhd"
"github.com/pterodactyl/wings/system"
)
type Filesystem struct {
mu sync.RWMutex
lastLookupTime *usageLookupTime
lookupInProgress *system.AtomicBool
diskUsed int64
diskCheckInterval time.Duration
denylist *ignore.GitIgnore
vhd *vhd.Disk
// 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
uuid string
isTest bool
}
// New creates a new Filesystem instance for a given server.
func New(uuid string, size int64, denylist []string) *Filesystem {
root := filepath.Join(config.Get().System.Data, uuid)
fs := Filesystem{
uuid: uuid,
root: root,
diskLimit: size,
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),
lastLookupTime: &usageLookupTime{},
lookupInProgress: system.NewAtomicBool(false),
denylist: ignore.CompileIgnoreLines(denylist...),
}
// If VHD support is enabled but this server is configured with no disk size
// limit we cannot actually use a virtual disk. In that case fall back to using
// the default driver.
if vhd.Enabled() && size > 0 {
fs.vhd = vhd.New(size, vhd.DiskPath(uuid), fs.root)
}
return &fs
}
// Path returns the root path for the Filesystem instance.
func (fs *Filesystem) Path() string {
return fs.root
}
// File returns a reader for a file instance as well as the stat information.
func (fs *Filesystem) File(p string) (*os.File, Stat, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, Stat{}, err
}
st, err := fs.Stat(cleaned)
if err != nil {
return nil, Stat{}, err
}
if st.IsDir() {
return nil, Stat{}, newFilesystemError(ErrCodeIsDirectory, nil)
}
f, err := os.Open(cleaned)
if err != nil {
return nil, Stat{}, err
}
return f, st, nil
}
// Touch acts by creating the given file and path on the disk if it is not present
// already. If it is present, the file is opened using the defaults which will
// truncate the contents. The opened file is then returned to the caller.
func (fs *Filesystem) Touch(p string, flag int) (*os.File, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
f, err := os.OpenFile(cleaned, flag, 0o644)
if err == nil {
return f, nil
}
// If the error is not because it doesn't exist then we just need to bail at this point.
if !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file handle")
}
// Only create and chown the directory if it doesn't exist.
if _, err := os.Stat(filepath.Dir(cleaned)); errors.Is(err, os.ErrNotExist) {
// Create the path leading up to the file we're trying to create, setting the final perms
// on it as we go.
if err := os.MkdirAll(filepath.Dir(cleaned), 0o755); err != nil {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to create directory tree")
}
if err := fs.Chown(filepath.Dir(cleaned)); err != nil {
return nil, err
}
}
o := &fileOpener{}
// Try to open the file now that we have created the pathing necessary for it, and then
// Chown that file so that the permissions don't mess with things.
f, err = o.open(cleaned, flag, 0o644)
if err != nil {
return nil, errors.Wrap(err, "server/filesystem: touch: failed to open file with wait")
}
_ = fs.Chown(cleaned)
return f, nil
}
// Writefile writes a file to the system. If the file does not already exist one
// will be created. This will also properly recalculate the disk space used by
// the server when writing new files or modifying existing ones.
func (fs *Filesystem) Writefile(p string, r io.Reader) error {
cleaned, err := fs.SafePath(p)
if err != nil {
return 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.
stat, err := os.Stat(cleaned)
if err != nil && !os.IsNotExist(err) {
return errors.Wrap(err, "server/filesystem: writefile: failed to stat file")
} else if err == nil {
if stat.IsDir() {
return errors.WithStack(&Error{code: ErrCodeIsDirectory, resolved: cleaned})
}
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
}
// Touch the file and return the handle to it at this point. This will create the file,
// any necessary directories, and set the proper owner of the file.
file, err := fs.Touch(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
if err != nil {
return err
}
defer file.Close()
buf := make([]byte, 1024*4)
sz, err := io.CopyBuffer(file, r, buf)
if err != nil {
if strings.Contains(err.Error(), "no space left on device") {
return newFilesystemError(ErrCodeDiskSpace, err)
}
return errors.WrapIf(err, "filesystem: failed to copy buffer for file write")
}
// Adjust the disk usage to account for the old size and the new size of the file.
fs.addDisk(sz - currentSize)
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 err
}
return os.MkdirAll(cleaned, 0o755)
}
// Rename 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, 0o755); mkerr != nil {
return errors.WithMessage(mkerr, "failed to create directory structure for file rename")
}
}
if err := os.Rename(cleanedFrom, cleanedTo); err != nil {
return errors.WithStack(err)
}
return nil
}
// 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 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.Wrap(err, "server/filesystem: chown: failed to chown path")
}
// If this is not a directory we can now return from the function, there is nothing
// left that we need to do.
if st, err := os.Stat(cleaned); err != nil || !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.
err = godirwalk.Walk(cleaned, &godirwalk.Options{
Unsorted: true,
Callback: func(p string, e *godirwalk.Dirent) error {
// Do not attempt to chown 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)
},
})
return errors.Wrap(err, "server/filesystem: chown: failed to chown during walk function")
}
func (fs *Filesystem) Chmod(path string, mode os.FileMode) error {
cleaned, err := fs.SafePath(path)
if err != nil {
return err
}
if fs.isTest {
return nil
}
if err := os.Chmod(cleaned, mode); err != nil {
return err
}
return nil
}
// 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
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 !errors.Is(err, os.ErrNotExist) {
return "", err
}
break
}
if i == 50 {
suffix = "copy." + time.Now().Format(time.RFC3339)
}
}
return name + suffix + extension, nil
}
// Copy takes a given input file path and creates a copy of the file at the same
// location, appending a unique number to the end. For example, a copy of "test.txt"
// would create "test 2.txt" as the copy, then "test 3.txt" and so on.
func (fs *Filesystem) Copy(p string) error {
cleaned, err := fs.SafePath(p)
if err != nil {
return err
}
s, err := os.Stat(cleaned)
if err != nil {
return 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 err
}
defer source.Close()
n, err := fs.findCopySuffix(relative, name, extension)
if err != nil {
return err
}
return fs.Writefile(path.Join(relative, n), source)
}
// TruncateRootDirectory removes _all_ files and directories from a server's
// data directory and resets the used disk space to zero.
func (fs *Filesystem) TruncateRootDirectory() error {
if err := os.RemoveAll(fs.Path()); err != nil {
return err
}
if err := os.Mkdir(fs.Path(), 0o755); err != nil {
return err
}
atomic.StoreInt64(&fs.diskUsed, 0)
return nil
}
// Delete removes 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 NewBadPathResolution(p, resolved)
}
// 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
}
}
// ListDirectory 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
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()))
}
// Don't try to detect the type on a pipe — this will just hang the application and
// you'll never get a response back.
//
// @see https://github.com/pterodactyl/panel/issues/4059
if cleanedp != "" && f.Mode()&os.ModeNamedPipe == 0 {
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{FileInfo: 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].Name() == out[j].Name() || out[i].Name() > out[j].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].IsDir()
})
return out, nil
}
func (fs *Filesystem) Chtimes(path string, atime, mtime time.Time) error {
cleaned, err := fs.SafePath(path)
if err != nil {
return err
}
if fs.isTest {
return nil
}
if err := os.Chtimes(cleaned, atime, mtime); err != nil {
return err
}
return nil
}