Massive refactor of SFTP system now that it is deeply integrated with Wings

This commit is contained in:
Dane Everitt 2021-01-10 14:25:39 -08:00
parent 96256ac63e
commit c228acaafc
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
7 changed files with 226 additions and 367 deletions

View File

@ -49,10 +49,8 @@ func (e *Error) Code() ErrorCode {
// Checks if the given error is one of the Filesystem errors. // Checks if the given error is one of the Filesystem errors.
func IsFilesystemError(err error) (*Error, bool) { func IsFilesystemError(err error) (*Error, bool) {
if e := errors.Unwrap(err); e != nil { var fserr *Error
err = e if errors.As(err, &fserr) {
}
if fserr, ok := err.(*Error); ok {
return fserr, true return fserr, true
} }
return nil, false return nil, false

View File

@ -2,11 +2,6 @@ package filesystem
import ( import (
"bufio" "bufio"
"emperror.dev/errors"
"github.com/gabriel-vasile/mimetype"
"github.com/karrick/godirwalk"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/system"
"io" "io"
"io/ioutil" "io/ioutil"
"os" "os"
@ -17,6 +12,12 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"emperror.dev/errors"
"github.com/gabriel-vasile/mimetype"
"github.com/karrick/godirwalk"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/system"
) )
type Filesystem struct { type Filesystem struct {
@ -71,6 +72,41 @@ func (fs *Filesystem) File(p string) (*os.File, os.FileInfo, error) {
return f, st, nil return f, st, nil
} }
// Acts by creating the given file and path on the disk if it is not present already. If
// it is present, the file is opened using the defaults which will truncate the contents.
// The opened file is then returned to the caller.
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, 0644)
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, err
}
// 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), 0755); err != nil {
return nil, err
}
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, 0644)
if err != nil {
return nil, err
}
_ = fs.Chown(cleaned)
return f, nil
}
// Reads a file on the system and returns it as a byte representation in a file // 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 // reader. This is not the most memory efficient usage since it will be reading the
// entirety of the file into memory. // entirety of the file into memory.
@ -112,22 +148,9 @@ func (fs *Filesystem) Writefile(p string, r io.Reader) error {
return err return err
} }
// If we were unable to stat the location because it did not exist, go ahead and create // Touch the file and return the handle to it at this point. This will create the file
// it now. We do this after checking the disk space so that we do not just create empty // and any necessary directories as needed.
// directories at random. file, err := fs.Touch(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
if err != nil {
if err := os.MkdirAll(filepath.Dir(cleaned), 0755); err != nil {
return err
}
if err := fs.Chown(filepath.Dir(cleaned)); 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 { if err != nil {
return err return err
} }
@ -150,7 +173,6 @@ func (fs *Filesystem) CreateDirectory(name string, p string) error {
if err != nil { if err != nil {
return err return err
} }
return os.MkdirAll(cleaned, 0755) return os.MkdirAll(cleaned, 0755)
} }

View File

@ -1,19 +0,0 @@
package sftp
type fxerr uint32
const (
// Extends the default SFTP server to return a quota exceeded error to the client.
//
// @see https://tools.ietf.org/id/draft-ietf-secsh-filexfer-13.txt
ErrSshQuotaExceeded = fxerr(15)
)
func (e fxerr) Error() string {
switch e {
case ErrSshQuotaExceeded:
return "Quota Exceeded"
default:
return "Failure"
}
}

View File

@ -5,31 +5,15 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"emperror.dev/errors"
"github.com/apex/log" "github.com/apex/log"
"github.com/patrickmn/go-cache"
"github.com/pkg/sftp" "github.com/pkg/sftp"
"github.com/pterodactyl/wings/server/filesystem"
) )
type FileSystem struct {
UUID string
Permissions []string
ReadOnly bool
User User
Cache *cache.Cache
PathValidator func(fs FileSystem, p string) (string, error)
HasDiskSpace func(fs FileSystem) bool
logger *log.Entry
lock sync.Mutex
}
func (fs FileSystem) buildPath(p string) (string, error) {
return fs.PathValidator(fs, p)
}
const ( const (
PermissionFileRead = "file.read" PermissionFileRead = "file.read"
PermissionFileReadContent = "file.read-content" PermissionFileReadContent = "file.read-content"
@ -38,343 +22,244 @@ const (
PermissionFileDelete = "file.delete" PermissionFileDelete = "file.delete"
) )
type Handler struct {
permissions []string
mu sync.Mutex
fs *filesystem.Filesystem
logger *log.Entry
ro bool
}
// Fileread creates a reader for a file on the system and returns the reader back. // Fileread creates a reader for a file on the system and returns the reader back.
func (fs FileSystem) Fileread(request *sftp.Request) (io.ReaderAt, error) { func (h *Handler) Fileread(request *sftp.Request) (io.ReaderAt, error) {
// Check first if the user can actually open and view a file. This permission is named // Check first if the user can actually open and view a file. This permission is named
// really poorly, but it is checking if they can read. There is an addition permission, // really poorly, but it is checking if they can read. There is an addition permission,
// "save-files" which determines if they can write that file. // "save-files" which determines if they can write that file.
if !fs.can(PermissionFileReadContent) { if !h.can(PermissionFileReadContent) {
return nil, sftp.ErrSshFxPermissionDenied return nil, sftp.ErrSSHFxPermissionDenied
} }
h.mu.Lock()
p, err := fs.buildPath(request.Filepath) defer h.mu.Unlock()
f, _, err := h.fs.File(request.Filepath)
if err != nil { if err != nil {
return nil, sftp.ErrSshFxNoSuchFile if !errors.Is(err, os.ErrNotExist) {
h.logger.WithField("error", err).Error("error processing readfile request")
return nil, sftp.ErrSSHFxFailure
}
return nil, sftp.ErrSSHFxNoSuchFile
} }
return f, nil
fs.lock.Lock()
defer fs.lock.Unlock()
if _, err := os.Stat(p); os.IsNotExist(err) {
return nil, sftp.ErrSshFxNoSuchFile
} else if err != nil {
fs.logger.WithField("error", err).Error("error while processing file stat")
return nil, sftp.ErrSshFxFailure
}
file, err := os.Open(p)
if err != nil {
fs.logger.WithField("source", p).WithField("error", err).Error("could not open file for reading")
return nil, sftp.ErrSshFxFailure
}
return file, nil
} }
// Filewrite handles the write actions for a file on the system. // Filewrite handles the write actions for a file on the system.
func (fs FileSystem) Filewrite(request *sftp.Request) (io.WriterAt, error) { func (h *Handler) Filewrite(request *sftp.Request) (io.WriterAt, error) {
if fs.ReadOnly { if h.ro {
return nil, sftp.ErrSshFxOpUnsupported return nil, sftp.ErrSSHFxOpUnsupported
} }
l := h.logger.WithField("source", request.Filepath)
p, err := fs.buildPath(request.Filepath)
if err != nil {
return nil, sftp.ErrSshFxNoSuchFile
}
l := fs.logger.WithField("source", p)
// If the user doesn't have enough space left on the server it should respond with an // If the user doesn't have enough space left on the server it should respond with an
// error since we won't be letting them write this file to the disk. // error since we won't be letting them write this file to the disk.
if !fs.HasDiskSpace(fs) { if !h.fs.HasSpaceAvailable(true) {
return nil, ErrSshQuotaExceeded return nil, ErrSSHQuotaExceeded
} }
fs.lock.Lock() h.mu.Lock()
defer fs.lock.Unlock() defer h.mu.Unlock()
// The specific permission required to perform this action. If the file exists on the
stat, statErr := os.Stat(p) // system already it only needs to be an update, otherwise we'll check for a create.
// If the file doesn't exist we need to create it, as well as the directory pathway permission := PermissionFileUpdate
// leading up to where that file will be created. _, sterr := h.fs.Stat(request.Filepath)
if os.IsNotExist(statErr) { if sterr != nil {
// This is a different pathway than just editing an existing file. If it doesn't exist already if !errors.Is(sterr, os.ErrNotExist) {
// we need to determine if this user has permission to create files. l.WithField("error", sterr).Error("error while getting file reader")
if !fs.can(PermissionFileCreate) { return nil, sftp.ErrSSHFxFailure
return nil, sftp.ErrSshFxPermissionDenied
} }
permission = PermissionFileCreate
// Create all of the directories leading up to the location where this file is being created.
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
l.WithFields(log.Fields{
"path": filepath.Dir(p),
"error": err,
}).Error("error making path for file")
return nil, sftp.ErrSshFxFailure
}
file, err := os.Create(p)
if err != nil {
l.WithField("error", err).Error("failed to create file")
return nil, sftp.ErrSshFxFailure
}
// Not failing here is intentional. We still made the file, it is just owned incorrectly
// and will likely cause some issues.
if err := os.Chown(p, fs.User.Uid, fs.User.Gid); err != nil {
l.WithField("error", err).Warn("failed to set permissions on file")
}
return file, nil
} }
// Confirm the user has permission to perform this action BEFORE calling Touch, otherwise
// If the stat error isn't about the file not existing, there is some other issue // you'll potentially create a file on the system and then fail out because of user
// at play and we need to go ahead and bail out of the process. // permission checking after the fact.
if statErr != nil { if !h.can(permission) {
l.WithField("error", statErr).Error("encountered error performing file stat") return nil, sftp.ErrSSHFxPermissionDenied
return nil, sftp.ErrSshFxFailure
} }
f, err := h.fs.Touch(request.Filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
// If we've made it here it means the file already exists and we don't need to do anything
// fancy to handle it. Just pass over the request flags so the system knows what the end
// goal with the file is going to be.
//
// But first, check that the user has permission to save modified files.
if !fs.can(PermissionFileUpdate) {
return nil, sftp.ErrSshFxPermissionDenied
}
// Not sure this would ever happen, but lets not find out.
if stat.IsDir() {
return nil, sftp.ErrSshFxOpUnsupported
}
file, err := os.Create(p)
if err != nil { if err != nil {
// Prevent errors if the file is deleted between the stat and this call.
if os.IsNotExist(err) {
return nil, sftp.ErrSSHFxNoSuchFile
}
l.WithField("flags", request.Flags).WithField("error", err).Error("failed to open existing file on system") l.WithField("flags", request.Flags).WithField("error", err).Error("failed to open existing file on system")
return nil, sftp.ErrSshFxFailure return nil, sftp.ErrSSHFxFailure
} }
return f, nil
// Not failing here is intentional. We still made the file, it is just owned incorrectly
// and will likely cause some issues.
if err := os.Chown(p, fs.User.Uid, fs.User.Gid); err != nil {
l.WithField("error", err).Warn("error chowning file")
}
return file, nil
} }
// Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading // Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading
// or writing to those files. // or writing to those files.
func (fs FileSystem) Filecmd(request *sftp.Request) error { func (h *Handler) Filecmd(request *sftp.Request) error {
if fs.ReadOnly { if h.ro {
return sftp.ErrSshFxOpUnsupported return sftp.ErrSSHFxOpUnsupported
} }
l := h.logger.WithField("source", request.Filepath)
p, err := fs.buildPath(request.Filepath)
if err != nil {
return sftp.ErrSshFxNoSuchFile
}
l := fs.logger.WithField("source", p)
var target string
// If a target is provided in this request validate that it is going to the correct
// location for the server. If it is not, return an operation unsupported error. This
// is maybe not the best error response, but its not wrong either.
if request.Target != "" { if request.Target != "" {
target, err = fs.buildPath(request.Target) l = l.WithField("target", request.Target)
if err != nil {
return sftp.ErrSshFxOpUnsupported
}
} }
switch request.Method { switch request.Method {
// Allows a user to make changes to the permissions of a given file or directory
// on their server using their SFTP client.
case "Setstat": case "Setstat":
if !fs.can(PermissionFileUpdate) { if !h.can(PermissionFileUpdate) {
return sftp.ErrSshFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
mode := request.Attributes().FileMode().Perm()
mode := os.FileMode(0644) // If the client passes an invalid FileMode just use the default 0644.
// If the client passed a valid file permission use that, otherwise use the if mode == 0000 {
// default of 0644 set above. mode = os.FileMode(0644)
if request.Attributes().FileMode().Perm() != 0000 {
mode = request.Attributes().FileMode().Perm()
} }
// Force directories to be 0755.
// Force directories to be 0755
if request.Attributes().FileMode().IsDir() { if request.Attributes().FileMode().IsDir() {
mode = 0755 mode = 0755
} }
if err := h.fs.Chmod(request.Filepath, mode); err != nil {
if err := os.Chmod(p, mode); err != nil { if errors.Is(err, os.ErrNotExist) {
if os.IsNotExist(err) {
return sftp.ErrSSHFxNoSuchFile return sftp.ErrSSHFxNoSuchFile
} }
l.WithField("error", err).Error("failed to perform setstat on item") l.WithField("error", err).Error("failed to perform setstat on item")
return sftp.ErrSSHFxFailure return sftp.ErrSSHFxFailure
} }
return nil break
// Support renaming a file (aka Move).
case "Rename": case "Rename":
if !fs.can(PermissionFileUpdate) { if !h.can(PermissionFileUpdate) {
return sftp.ErrSSHFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
if err := h.fs.Rename(request.Filepath, request.Target); err != nil {
if err := os.Rename(p, target); err != nil { if errors.Is(err, os.ErrNotExist) {
if os.IsNotExist(err) {
return sftp.ErrSSHFxNoSuchFile return sftp.ErrSSHFxNoSuchFile
} }
l.WithField("error", err).Error("failed to rename file")
l.WithField("target", target).WithField("error", err).Error("failed to rename file") return sftp.ErrSSHFxFailure
return sftp.ErrSshFxFailure
} }
break break
// Handle deletion of a directory. This will properly delete all of the files and
// folders within that directory if it is not already empty (unlike a lot of SFTP
// clients that must delete each file individually).
case "Rmdir": case "Rmdir":
if !fs.can(PermissionFileDelete) { if !h.can(PermissionFileDelete) {
return sftp.ErrSshFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
if err := h.fs.Delete(request.Filepath); err != nil {
if err := os.RemoveAll(p); err != nil {
l.WithField("error", err).Error("failed to remove directory") l.WithField("error", err).Error("failed to remove directory")
return sftp.ErrSSHFxFailure
return sftp.ErrSshFxFailure
} }
return sftp.ErrSSHFxOk
return sftp.ErrSshFxOk // Handle requests to create a new Directory.
case "Mkdir": case "Mkdir":
if !fs.can(PermissionFileCreate) { if !h.can(PermissionFileCreate) {
return sftp.ErrSshFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
name := strings.Split(filepath.Clean(request.Filepath), "/")
if err := os.MkdirAll(p, 0755); err != nil { err := h.fs.CreateDirectory(name[len(name)-1], strings.Join(name[0:len(name)-1], "/"))
if err != nil {
l.WithField("error", err).Error("failed to create directory") l.WithField("error", err).Error("failed to create directory")
return sftp.ErrSSHFxFailure
return sftp.ErrSshFxFailure
} }
break break
// Support creating symlinks between files. The source and target must resolve within
// the server home directory.
case "Symlink": case "Symlink":
if !fs.can(PermissionFileCreate) { if !h.can(PermissionFileCreate) {
return sftp.ErrSshFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
source, err := h.fs.SafePath(request.Filepath)
if err := os.Symlink(p, target); err != nil { if err != nil {
return sftp.ErrSSHFxNoSuchFile
}
target, err := h.fs.SafePath(request.Target)
if err != nil {
return sftp.ErrSSHFxNoSuchFile
}
if err := os.Symlink(source, target); err != nil {
l.WithField("target", target).WithField("error", err).Error("failed to create symlink") l.WithField("target", target).WithField("error", err).Error("failed to create symlink")
return sftp.ErrSSHFxFailure
return sftp.ErrSshFxFailure
} }
break break
// Called when deleting a file.
case "Remove": case "Remove":
if !fs.can(PermissionFileDelete) { if !h.can(PermissionFileDelete) {
return sftp.ErrSshFxPermissionDenied return sftp.ErrSSHFxPermissionDenied
} }
if err := h.fs.Delete(request.Filepath); err != nil {
if err := os.Remove(p); err != nil { if errors.Is(err, os.ErrNotExist) {
if os.IsNotExist(err) {
return sftp.ErrSSHFxNoSuchFile return sftp.ErrSSHFxNoSuchFile
} }
l.WithField("error", err).Error("failed to remove a file") l.WithField("error", err).Error("failed to remove a file")
return sftp.ErrSSHFxFailure
return sftp.ErrSshFxFailure
} }
return sftp.ErrSSHFxOk
return sftp.ErrSshFxOk
default: default:
return sftp.ErrSshFxOpUnsupported return sftp.ErrSSHFxOpUnsupported
} }
var fileLocation = p target := request.Filepath
if target != "" { if request.Target != "" {
fileLocation = target target = request.Target
} }
// Not failing here is intentional. We still made the file, it is just owned incorrectly // Not failing here is intentional. We still made the file, it is just owned incorrectly
// and will likely cause some issues. There is no logical check for if the file was removed // and will likely cause some issues. There is no logical check for if the file was removed
// because both of those cases (Rmdir, Remove) have an explicit return rather than break. // because both of those cases (Rmdir, Remove) have an explicit return rather than break.
if err := os.Chown(fileLocation, fs.User.Uid, fs.User.Gid); err != nil { if err := h.fs.Chown(target); err != nil {
l.WithField("error", err).Warn("error chowning file") l.WithField("error", err).Warn("error chowning file")
} }
return sftp.ErrSshFxOk return sftp.ErrSSHFxOk
} }
// Filelist is the handler for SFTP filesystem list calls. This will handle calls to list the contents of // Filelist is the handler for SFTP filesystem list calls. This will handle calls to list the contents of
// a directory as well as perform file/folder stat calls. // a directory as well as perform file/folder stat calls.
func (fs FileSystem) Filelist(request *sftp.Request) (sftp.ListerAt, error) { func (h *Handler) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
p, err := fs.buildPath(request.Filepath) if !h.can(PermissionFileRead) {
if err != nil { return nil, sftp.ErrSSHFxPermissionDenied
return nil, sftp.ErrSshFxNoSuchFile
} }
switch request.Method { switch request.Method {
case "List": case "List":
if !fs.can(PermissionFileRead) { p, err := h.fs.SafePath(request.Filepath)
return nil, sftp.ErrSshFxPermissionDenied if err != nil {
return nil, sftp.ErrSSHFxNoSuchFile
} }
files, err := ioutil.ReadDir(p) files, err := ioutil.ReadDir(p)
if err != nil { if err != nil {
fs.logger.WithField("error", err).Error("error while listing directory") h.logger.WithField("source", request.Filepath).WithField("error", err).Error("error while listing directory")
return nil, sftp.ErrSshFxFailure return nil, sftp.ErrSSHFxFailure
} }
return ListerAt(files), nil return ListerAt(files), nil
case "Stat": case "Stat":
if !fs.can(PermissionFileRead) { st, err := h.fs.Stat(request.Filepath)
return nil, sftp.ErrSshFxPermissionDenied if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, sftp.ErrSSHFxNoSuchFile
}
h.logger.WithField("source", request.Filepath).WithField("error", err).Error("error performing stat on file")
return nil, sftp.ErrSSHFxFailure
} }
return ListerAt([]os.FileInfo{st.Info}), nil
s, err := os.Stat(p)
if os.IsNotExist(err) {
return nil, sftp.ErrSshFxNoSuchFile
} else if err != nil {
fs.logger.WithField("source", p).WithField("error", err).Error("error performing stat on file")
return nil, sftp.ErrSshFxFailure
}
return ListerAt([]os.FileInfo{s}), nil
default: default:
// Before adding readlink support we need to evaluate any potential security risks return nil, sftp.ErrSSHFxOpUnsupported
// as a result of navigating around to a location that is outside the home directory
// for the logged in user. I don't foresee it being much of a problem, but I do want to
// check it out before slapping some code here. Until then, we'll just return an
// unsupported response code.
return nil, sftp.ErrSshFxOpUnsupported
} }
} }
// Determines if a user has permission to perform a specific action on the SFTP server. These // Determines if a user has permission to perform a specific action on the SFTP server. These
// permissions are defined and returned by the Panel API. // permissions are defined and returned by the Panel API.
func (fs FileSystem) can(permission string) bool { func (h *Handler) can(permission string) bool {
// Server owners and super admins have their permissions returned as '[*]' via the Panel // SFTPServer owners and super admins have their permissions returned as '[*]' via the Panel
// API, so for the sake of speed do an initial check for that before iterating over the // API, so for the sake of speed do an initial check for that before iterating over the
// entire array of permissions. // entire array of permissions.
if len(fs.Permissions) == 1 && fs.Permissions[0] == "*" { if len(h.permissions) == 1 && h.permissions[0] == "*" {
return true return true
} }
for _, p := range h.permissions {
// Not the owner or an admin, loop over the permissions that were returned to determine
// if they have the passed permission.
for _, p := range fs.Permissions {
if p == permission { if p == permission {
return true return true
} }
} }
return false return false
} }

View File

@ -12,12 +12,12 @@ import (
"os" "os"
"path" "path"
"strings" "strings"
"time"
"github.com/apex/log" "github.com/apex/log"
"github.com/patrickmn/go-cache"
"github.com/pkg/sftp" "github.com/pkg/sftp"
"github.com/pterodactyl/wings/api" "github.com/pterodactyl/wings/api"
"github.com/pterodactyl/wings/config"
"github.com/pterodactyl/wings/server"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -33,35 +33,23 @@ type User struct {
Gid int Gid int
} }
type Server struct { //goland:noinspection GoNameStartsWithPackageName
cache *cache.Cache type SFTPServer struct {
Settings Settings Settings Settings
User User User User
PathValidator func(fs FileSystem, p string) (string, error)
DiskSpaceValidator func(fs FileSystem) bool
// Validator function that is called when a user connects to the server. This should // Validator function that is called when a user connects to the server. This should
// check against whatever system is desired to confirm if the given username and password // check against whatever system is desired to confirm if the given username and password
// combination is valid. If so, should return an authentication response. // combination is valid. If so, should return an authentication response.
CredentialValidator func(r api.SftpAuthRequest) (*api.SftpAuthResponse, error) credentialValidator func(r api.SftpAuthRequest) (*api.SftpAuthResponse, error)
}
// Create a new server configuration instance.
func New(c *Server) error {
c.cache = cache.New(5*time.Minute, 10*time.Minute)
return nil
} }
// Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections. // Initialize the SFTP server and add a persistent listener to handle inbound SFTP connections.
func (c *Server) Initialize() error { func (c *SFTPServer) Initialize() error {
serverConfig := &ssh.ServerConfig{ serverConfig := &ssh.ServerConfig{
NoClientAuth: false, NoClientAuth: false,
MaxAuthTries: 6, MaxAuthTries: 6,
PasswordCallback: func(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { PasswordCallback: func(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
resp, err := c.CredentialValidator(api.SftpAuthRequest{ resp, err := c.credentialValidator(api.SftpAuthRequest{
User: conn.User(), User: conn.User(),
Pass: string(pass), Pass: string(pass),
IP: conn.RemoteAddr().String(), IP: conn.RemoteAddr().String(),
@ -123,7 +111,7 @@ func (c *Server) Initialize() error {
// Handles an inbound connection to the instance and determines if we should serve the request // Handles an inbound connection to the instance and determines if we should serve the request
// or not. // or not.
func (c Server) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig) { func (c SFTPServer) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig) {
defer conn.Close() defer conn.Close()
// Before beginning a handshake must be performed on the incoming net.Conn // Before beginning a handshake must be performed on the incoming net.Conn
@ -165,19 +153,17 @@ func (c Server) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig)
} }
}(requests) }(requests)
// Configure the user's home folder for the rest of the request cycle.
if sconn.Permissions.Extensions["uuid"] == "" { if sconn.Permissions.Extensions["uuid"] == "" {
continue continue
} }
// Create a new handler for the currently logged in user's server. // Create a new handler for the currently logged in user's server.
fs := c.createHandler(sconn) fs := c.newHandler(sconn)
// Create the server instance for the channel using the filesystem we created above. // Create the server instance for the channel using the filesystem we created above.
server := sftp.NewRequestServer(channel, fs) handler := sftp.NewRequestServer(channel, fs)
if err := handler.Serve(); err == io.EOF {
if err := server.Serve(); err == io.EOF { handler.Close()
server.Close()
} }
} }
} }
@ -185,15 +171,15 @@ func (c Server) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig)
// Creates a new SFTP handler for a given server. The directory argument should // Creates a new SFTP handler for a given server. The directory argument should
// be the base directory for a server. All actions done on the server will be // be the base directory for a server. All actions done on the server will be
// relative to that directory, and the user will not be able to escape out of it. // relative to that directory, and the user will not be able to escape out of it.
func (c Server) createHandler(sc *ssh.ServerConn) sftp.Handlers { func (c SFTPServer) newHandler(sc *ssh.ServerConn) sftp.Handlers {
p := FileSystem{ s := server.GetServers().Find(func(s *server.Server) bool {
UUID: sc.Permissions.Extensions["uuid"], return s.Id() == sc.Permissions.Extensions["uuid"]
Permissions: strings.Split(sc.Permissions.Extensions["permissions"], ","), })
ReadOnly: c.Settings.ReadOnly,
Cache: c.cache, p := Handler{
User: c.User, fs: s.Filesystem(),
HasDiskSpace: c.DiskSpaceValidator, permissions: strings.Split(sc.Permissions.Extensions["permissions"], ","),
PathValidator: c.PathValidator, ro: config.Get().System.Sftp.ReadOnly,
logger: log.WithFields(log.Fields{ logger: log.WithFields(log.Fields{
"subsystem": "sftp", "subsystem": "sftp",
"username": sc.User(), "username": sc.User(),
@ -202,15 +188,15 @@ func (c Server) createHandler(sc *ssh.ServerConn) sftp.Handlers {
} }
return sftp.Handlers{ return sftp.Handlers{
FileGet: p, FileGet: &p,
FilePut: p, FilePut: &p,
FileCmd: p, FileCmd: &p,
FileList: p, FileList: &p,
} }
} }
// Generates a private key that will be used by the SFTP server. // Generates a private key that will be used by the SFTP server.
func (c Server) generatePrivateKey() error { func (c SFTPServer) generatePrivateKey() error {
key, err := rsa.GenerateKey(rand.Reader, 2048) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
return err return err

View File

@ -11,7 +11,7 @@ import (
var noMatchingServerError = errors.New("no matching server with that UUID was found") var noMatchingServerError = errors.New("no matching server with that UUID was found")
func Initialize(config config.SystemConfiguration) error { func Initialize(config config.SystemConfiguration) error {
s := &Server{ s := &SFTPServer{
User: User{ User: User{
Uid: config.User.Uid, Uid: config.User.Uid,
Gid: config.User.Gid, Gid: config.User.Gid,
@ -22,18 +22,12 @@ func Initialize(config config.SystemConfiguration) error {
BindAddress: config.Sftp.Address, BindAddress: config.Sftp.Address,
BindPort: config.Sftp.Port, BindPort: config.Sftp.Port,
}, },
CredentialValidator: validateCredentials, credentialValidator: validateCredentials,
PathValidator: validatePath,
DiskSpaceValidator: validateDiskSpace,
}
if err := New(s); err != nil {
return err
} }
// Initialize the SFTP server in a background thread since this is // Initialize the SFTP server in a background thread since this is
// a long running operation. // a long running operation.
go func(s *Server) { go func(s *SFTPServer) {
if err := s.Initialize(); err != nil { if err := s.Initialize(); err != nil {
log.WithField("subsystem", "sftp").WithField("error", err).Error("failed to initialize SFTP subsystem") log.WithField("subsystem", "sftp").WithField("error", err).Error("failed to initialize SFTP subsystem")
} }
@ -42,30 +36,6 @@ func Initialize(config config.SystemConfiguration) error {
return nil return nil
} }
func validatePath(fs FileSystem, p string) (string, error) {
s := server.GetServers().Find(func(server *server.Server) bool {
return server.Id() == fs.UUID
})
if s == nil {
return "", noMatchingServerError
}
return s.Filesystem().SafePath(p)
}
func validateDiskSpace(fs FileSystem) bool {
s := server.GetServers().Find(func(server *server.Server) bool {
return server.Id() == fs.UUID
})
if s == nil {
return false
}
return s.Filesystem().HasSpaceAvailable(true)
}
// Validates a set of credentials for a SFTP login against Pterodactyl Panel and returns // Validates a set of credentials for a SFTP login against Pterodactyl Panel and returns
// the server's UUID if the credentials were valid. // the server's UUID if the credentials were valid.
func validateCredentials(c api.SftpAuthRequest) (*api.SftpAuthResponse, error) { func validateCredentials(c api.SftpAuthRequest) (*api.SftpAuthResponse, error) {

View File

@ -6,6 +6,14 @@ import (
) )
type ListerAt []os.FileInfo type ListerAt []os.FileInfo
type fxerr uint32
const (
// Extends the default SFTP server to return a quota exceeded error to the client.
//
// @see https://tools.ietf.org/id/draft-ietf-secsh-filexfer-13.txt
ErrSSHQuotaExceeded = fxerr(15)
)
// Returns the number of entries copied and an io.EOF error if we made it to the end of the file list. // Returns the number of entries copied and an io.EOF error if we made it to the end of the file list.
// Take a look at the pkg/sftp godoc for more information about how this function should work. // Take a look at the pkg/sftp godoc for more information about how this function should work.
@ -20,3 +28,12 @@ func (l ListerAt) ListAt(f []os.FileInfo, offset int64) (int, error) {
return n, nil return n, nil
} }
} }
func (e fxerr) Error() string {
switch e {
case ErrSSHQuotaExceeded:
return "Quota Exceeded"
default:
return "Failure"
}
}