diff --git a/api/sftp_endpoints.go b/api/sftp_endpoints.go index b1e0f39..e222749 100644 --- a/api/sftp_endpoints.go +++ b/api/sftp_endpoints.go @@ -62,12 +62,6 @@ func (r *Request) ValidateSftpCredentials(request SftpAuthRequest) (*SftpAuthRes e := resp.Error() if e != nil { if resp.StatusCode >= 400 && resp.StatusCode < 500 { - log.WithFields(log.Fields{ - "subsystem": "sftp", - "username": request.User, - "ip": request.IP, - }).Warn(e.Error()) - return nil, &sftpInvalidCredentialsError{} } diff --git a/cmd/root.go b/cmd/root.go index 4ddf4f2..18dccb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -272,8 +272,8 @@ func rootCmdRun(cmd *cobra.Command, _ []string) { // Wait until all of the servers are ready to go before we fire up the SFTP and HTTP servers. pool.StopWait() - // Initialize the SFTP server. - if err := sftp.Initialize(c.System); err != nil { + // Run the SFTP server. + if err := sftp.New().Run(); err != nil { log.WithError(err).Fatal("failed to initialize the sftp server") return } diff --git a/server/filesystem/errors.go b/server/filesystem/errors.go index 52a2c20..a5890bd 100644 --- a/server/filesystem/errors.go +++ b/server/filesystem/errors.go @@ -49,10 +49,8 @@ func (e *Error) Code() ErrorCode { // Checks if the given error is one of the Filesystem errors. func IsFilesystemError(err error) (*Error, bool) { - if e := errors.Unwrap(err); e != nil { - err = e - } - if fserr, ok := err.(*Error); ok { + var fserr *Error + if errors.As(err, &fserr) { return fserr, true } return nil, false diff --git a/server/filesystem/filesystem.go b/server/filesystem/filesystem.go index 2e0bd88..a4a49de 100644 --- a/server/filesystem/filesystem.go +++ b/server/filesystem/filesystem.go @@ -2,11 +2,6 @@ package filesystem import ( "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/ioutil" "os" @@ -17,6 +12,12 @@ import ( "strings" "sync" "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 { @@ -71,6 +72,41 @@ func (fs *Filesystem) File(p string) (*os.File, os.FileInfo, error) { 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 // reader. This is not the most memory efficient usage since it will be reading the // entirety of the file into memory. @@ -112,22 +148,9 @@ func (fs *Filesystem) Writefile(p string, r io.Reader) error { return err } - // If we were unable to stat the location because it did not exist, go ahead and create - // it now. We do this after checking the disk space so that we do not just create empty - // directories at random. - 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) + // Touch the file and return the handle to it at this point. This will create the file + // and any necessary directories as needed. + file, err := fs.Touch(cleaned, os.O_RDWR|os.O_CREATE|os.O_TRUNC) if err != nil { return err } @@ -150,7 +173,6 @@ func (fs *Filesystem) CreateDirectory(name string, p string) error { if err != nil { return err } - return os.MkdirAll(cleaned, 0755) } diff --git a/server/listeners.go b/server/listeners.go index 767cbf1..77f6cf6 100644 --- a/server/listeners.go +++ b/server/listeners.go @@ -136,7 +136,7 @@ func (s *Server) StartEventListeners() { } } - s.Log().Info("registering event listeners: console, state, resources...") + s.Log().Debug("registering event listeners: console, state, resources...") s.Environment.Events().On(environment.ConsoleOutputEvent, &console) s.Environment.Events().On(environment.StateChangeEvent, &state) s.Environment.Events().On(environment.ResourceEvent, &stats) diff --git a/sftp/errors.go b/sftp/errors.go deleted file mode 100644 index 122aa15..0000000 --- a/sftp/errors.go +++ /dev/null @@ -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" - } -} diff --git a/sftp/handler.go b/sftp/handler.go index 68eed8c..1461876 100644 --- a/sftp/handler.go +++ b/sftp/handler.go @@ -5,31 +5,17 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "sync" + "emperror.dev/errors" "github.com/apex/log" - "github.com/patrickmn/go-cache" "github.com/pkg/sftp" + "github.com/pterodactyl/wings/config" + "github.com/pterodactyl/wings/server/filesystem" + "golang.org/x/crypto/ssh" ) -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 ( PermissionFileRead = "file.read" PermissionFileReadContent = "file.read-content" @@ -38,343 +24,269 @@ const ( PermissionFileDelete = "file.delete" ) +type Handler struct { + permissions []string + mu sync.Mutex + fs *filesystem.Filesystem + logger *log.Entry + ro bool +} + +// Returns a new connection handler for the SFTP server. This allows a given user +// to access the underlying filesystem. +func NewHandler(sc *ssh.ServerConn, fs *filesystem.Filesystem) *Handler { + return &Handler{ + fs: fs, + ro: config.Get().System.Sftp.ReadOnly, + permissions: strings.Split(sc.Permissions.Extensions["permissions"], ","), + logger: log.WithFields(log.Fields{ + "subsystem": "sftp", + "username": sc.User(), + "ip": sc.RemoteAddr(), + }), + } +} + +// Returns the sftp.Handlers for this struct. +func (h *Handler) Handlers() sftp.Handlers { + return sftp.Handlers{ + FileGet: h, + FilePut: h, + FileCmd: h, + FileList: h, + } +} + // 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 // 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. - if !fs.can(PermissionFileReadContent) { - return nil, sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileReadContent) { + return nil, sftp.ErrSSHFxPermissionDenied } - - p, err := fs.buildPath(request.Filepath) + h.mu.Lock() + defer h.mu.Unlock() + f, _, err := h.fs.File(request.Filepath) 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 } - - 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 + return f, nil } // Filewrite handles the write actions for a file on the system. -func (fs FileSystem) Filewrite(request *sftp.Request) (io.WriterAt, error) { - if fs.ReadOnly { - return nil, sftp.ErrSshFxOpUnsupported +func (h *Handler) Filewrite(request *sftp.Request) (io.WriterAt, error) { + if h.ro { + return nil, sftp.ErrSSHFxOpUnsupported } - - p, err := fs.buildPath(request.Filepath) - if err != nil { - return nil, sftp.ErrSshFxNoSuchFile - } - - l := fs.logger.WithField("source", p) - + l := h.logger.WithField("source", request.Filepath) // 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. - if !fs.HasDiskSpace(fs) { - return nil, ErrSshQuotaExceeded + if !h.fs.HasSpaceAvailable(true) { + return nil, ErrSSHQuotaExceeded } - fs.lock.Lock() - defer fs.lock.Unlock() - - stat, statErr := os.Stat(p) - // If the file doesn't exist we need to create it, as well as the directory pathway - // leading up to where that file will be created. - if os.IsNotExist(statErr) { - // This is a different pathway than just editing an existing file. If it doesn't exist already - // we need to determine if this user has permission to create files. - if !fs.can(PermissionFileCreate) { - return nil, sftp.ErrSshFxPermissionDenied + h.mu.Lock() + defer h.mu.Unlock() + // The specific permission required to perform this action. If the file exists on the + // system already it only needs to be an update, otherwise we'll check for a create. + permission := PermissionFileUpdate + _, sterr := h.fs.Stat(request.Filepath) + if sterr != nil { + if !errors.Is(sterr, os.ErrNotExist) { + l.WithField("error", sterr).Error("error while getting file reader") + return nil, sftp.ErrSSHFxFailure } - - // 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 + permission = PermissionFileCreate } - - // If the stat error isn't about the file not existing, there is some other issue - // at play and we need to go ahead and bail out of the process. - if statErr != nil { - l.WithField("error", statErr).Error("encountered error performing file stat") - - return nil, sftp.ErrSshFxFailure + // Confirm the user has permission to perform this action BEFORE calling Touch, otherwise + // you'll potentially create a file on the system and then fail out because of user + // permission checking after the fact. + if !h.can(permission) { + return nil, sftp.ErrSSHFxPermissionDenied } - - // 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) + f, err := h.fs.Touch(request.Filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC) 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") - return nil, sftp.ErrSshFxFailure + 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("error chowning file") - } - - return file, nil + return f, nil } // Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading // or writing to those files. -func (fs FileSystem) Filecmd(request *sftp.Request) error { - if fs.ReadOnly { - return sftp.ErrSshFxOpUnsupported +func (h *Handler) Filecmd(request *sftp.Request) error { + if h.ro { + return sftp.ErrSSHFxOpUnsupported } - - 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. + l := h.logger.WithField("source", request.Filepath) if request.Target != "" { - target, err = fs.buildPath(request.Target) - if err != nil { - return sftp.ErrSshFxOpUnsupported - } + l = l.WithField("target", request.Target) } 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": - if !fs.can(PermissionFileUpdate) { - return sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileUpdate) { + return sftp.ErrSSHFxPermissionDenied } - - mode := os.FileMode(0644) - // If the client passed a valid file permission use that, otherwise use the - // default of 0644 set above. - if request.Attributes().FileMode().Perm() != 0000 { - mode = request.Attributes().FileMode().Perm() + mode := request.Attributes().FileMode().Perm() + // If the client passes an invalid FileMode just use the default 0644. + if mode == 0000 { + mode = os.FileMode(0644) } - - // Force directories to be 0755 + // Force directories to be 0755. if request.Attributes().FileMode().IsDir() { mode = 0755 } - - if err := os.Chmod(p, mode); err != nil { - if os.IsNotExist(err) { + if err := h.fs.Chmod(request.Filepath, mode); err != nil { + if errors.Is(err, os.ErrNotExist) { return sftp.ErrSSHFxNoSuchFile } - l.WithField("error", err).Error("failed to perform setstat on item") return sftp.ErrSSHFxFailure } - return nil + break + // Support renaming a file (aka Move). case "Rename": - if !fs.can(PermissionFileUpdate) { + if !h.can(PermissionFileUpdate) { return sftp.ErrSSHFxPermissionDenied } - - if err := os.Rename(p, target); err != nil { - if os.IsNotExist(err) { + if err := h.fs.Rename(request.Filepath, request.Target); err != nil { + if errors.Is(err, os.ErrNotExist) { return sftp.ErrSSHFxNoSuchFile } - - l.WithField("target", target).WithField("error", err).Error("failed to rename file") - - return sftp.ErrSshFxFailure + l.WithField("error", err).Error("failed to rename file") + return sftp.ErrSSHFxFailure } - 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": - if !fs.can(PermissionFileDelete) { - return sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileDelete) { + return sftp.ErrSSHFxPermissionDenied } - - if err := os.RemoveAll(p); err != nil { + if err := h.fs.Delete(request.Filepath); err != nil { 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": - if !fs.can(PermissionFileCreate) { - return sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileCreate) { + return sftp.ErrSSHFxPermissionDenied } - - if err := os.MkdirAll(p, 0755); err != nil { + name := strings.Split(filepath.Clean(request.Filepath), "/") + 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") - - return sftp.ErrSshFxFailure + return sftp.ErrSSHFxFailure } - break + // Support creating symlinks between files. The source and target must resolve within + // the server home directory. case "Symlink": - if !fs.can(PermissionFileCreate) { - return sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileCreate) { + return sftp.ErrSSHFxPermissionDenied } - - if err := os.Symlink(p, target); err != nil { + source, err := h.fs.SafePath(request.Filepath) + 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") - - return sftp.ErrSshFxFailure + return sftp.ErrSSHFxFailure } - break + // Called when deleting a file. case "Remove": - if !fs.can(PermissionFileDelete) { - return sftp.ErrSshFxPermissionDenied + if !h.can(PermissionFileDelete) { + return sftp.ErrSSHFxPermissionDenied } - - if err := os.Remove(p); err != nil { - if os.IsNotExist(err) { + if err := h.fs.Delete(request.Filepath); err != nil { + if errors.Is(err, os.ErrNotExist) { return sftp.ErrSSHFxNoSuchFile } - l.WithField("error", err).Error("failed to remove a file") - - return sftp.ErrSshFxFailure + return sftp.ErrSSHFxFailure } - - return sftp.ErrSshFxOk + return sftp.ErrSSHFxOk default: - return sftp.ErrSshFxOpUnsupported + return sftp.ErrSSHFxOpUnsupported } - var fileLocation = p - if target != "" { - fileLocation = target + target := request.Filepath + if request.Target != "" { + target = request.Target } - // 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 // 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") } - return sftp.ErrSshFxOk + return sftp.ErrSSHFxOk } // 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. -func (fs FileSystem) Filelist(request *sftp.Request) (sftp.ListerAt, error) { - p, err := fs.buildPath(request.Filepath) - if err != nil { - return nil, sftp.ErrSshFxNoSuchFile +func (h *Handler) Filelist(request *sftp.Request) (sftp.ListerAt, error) { + if !h.can(PermissionFileRead) { + return nil, sftp.ErrSSHFxPermissionDenied } switch request.Method { case "List": - if !fs.can(PermissionFileRead) { - return nil, sftp.ErrSshFxPermissionDenied + p, err := h.fs.SafePath(request.Filepath) + if err != nil { + return nil, sftp.ErrSSHFxNoSuchFile } - files, err := ioutil.ReadDir(p) 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 case "Stat": - if !fs.can(PermissionFileRead) { - return nil, sftp.ErrSshFxPermissionDenied + st, err := h.fs.Stat(request.Filepath) + 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 } - - 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 + return ListerAt([]os.FileInfo{st.Info}), nil default: - // Before adding readlink support we need to evaluate any potential security risks - // 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 + return nil, sftp.ErrSSHFxOpUnsupported } } // 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. -func (fs FileSystem) can(permission string) bool { - // Server owners and super admins have their permissions returned as '[*]' via the Panel +func (h *Handler) can(permission string) bool { + // 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 // entire array of permissions. - if len(fs.Permissions) == 1 && fs.Permissions[0] == "*" { + if len(h.permissions) == 1 && h.permissions[0] == "*" { return true } - - // 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 { + for _, p := range h.permissions { if p == permission { return true } } - return false } diff --git a/sftp/server.go b/sftp/server.go index a0ae24b..b50650d 100644 --- a/sftp/server.go +++ b/sftp/server.go @@ -5,235 +5,192 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" - "fmt" "io" "io/ioutil" "net" "os" "path" + "strconv" "strings" - "time" + "emperror.dev/errors" "github.com/apex/log" - "github.com/patrickmn/go-cache" "github.com/pkg/sftp" "github.com/pterodactyl/wings/api" + "github.com/pterodactyl/wings/config" + "github.com/pterodactyl/wings/server" "golang.org/x/crypto/ssh" ) -type Settings struct { - BasePath string - ReadOnly bool - BindPort int - BindAddress string +//goland:noinspection GoNameStartsWithPackageName +type SFTPServer struct { + BasePath string + ReadOnly bool + Listen string } -type User struct { - Uid int - Gid int -} - -type Server struct { - cache *cache.Cache - - Settings Settings - 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 - // check against whatever system is desired to confirm if the given username and password - // combination is valid. If so, should return an authentication response. - 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. -func (c *Server) Initialize() error { - serverConfig := &ssh.ServerConfig{ - NoClientAuth: false, - MaxAuthTries: 6, - PasswordCallback: func(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { - resp, err := c.CredentialValidator(api.SftpAuthRequest{ - User: conn.User(), - Pass: string(pass), - IP: conn.RemoteAddr().String(), - SessionID: conn.SessionID(), - ClientVersion: conn.ClientVersion(), - }) - - if err != nil { - return nil, err - } - - sshPerm := &ssh.Permissions{ - Extensions: map[string]string{ - "uuid": resp.Server, - "user": conn.User(), - "permissions": strings.Join(resp.Permissions, ","), - }, - } - - return sshPerm, nil - }, +func New() *SFTPServer { + cfg := config.Get().System + return &SFTPServer{ + BasePath: cfg.Data, + ReadOnly: cfg.Sftp.ReadOnly, + Listen: cfg.Sftp.Address + ":" + strconv.Itoa(cfg.Sftp.Port), } +} - if _, err := os.Stat(path.Join(c.Settings.BasePath, ".sftp/id_rsa")); os.IsNotExist(err) { +// Starts the SFTP server and add a persistent listener to handle inbound SFTP connections. +func (c *SFTPServer) Run() error { + if _, err := os.Stat(path.Join(c.BasePath, ".sftp/id_rsa")); os.IsNotExist(err) { if err := c.generatePrivateKey(); err != nil { return err } } else if err != nil { - return err + return errors.Wrap(err, "sftp/server: could not stat private key file") } - - privateBytes, err := ioutil.ReadFile(path.Join(c.Settings.BasePath, ".sftp/id_rsa")) + pb, err := ioutil.ReadFile(path.Join(c.BasePath, ".sftp/id_rsa")) + if err != nil { + return errors.Wrap(err, "sftp/server: could not read private key file") + } + private, err := ssh.ParsePrivateKey(pb) if err != nil { return err } - private, err := ssh.ParsePrivateKey(privateBytes) + conf := &ssh.ServerConfig{ + NoClientAuth: false, + MaxAuthTries: 6, + PasswordCallback: c.passwordCallback, + } + conf.AddHostKey(private) + + listener, err := net.Listen("tcp", c.Listen) if err != nil { return err } - // Add our private key to the server configuration. - serverConfig.AddHostKey(private) - - listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", c.Settings.BindAddress, c.Settings.BindPort)) - if err != nil { - return err - } - - log.WithField("host", c.Settings.BindAddress).WithField("port", c.Settings.BindPort).Info("sftp subsystem listening for connections") - + log.WithField("listen", c.Listen).Info("sftp server listening for connections") for { - conn, _ := listener.Accept() - if conn != nil { - go c.AcceptInboundConnection(conn, serverConfig) + if conn, _ := listener.Accept(); conn != nil { + go func(conn net.Conn) { + defer conn.Close() + c.AcceptInbound(conn, conf) + }(conn) } } } -// Handles an inbound connection to the instance and determines if we should serve the request -// or not. -func (c Server) AcceptInboundConnection(conn net.Conn, config *ssh.ServerConfig) { - defer conn.Close() - +// Handles an inbound connection to the instance and determines if we should serve the +// request or not. +func (c SFTPServer) AcceptInbound(conn net.Conn, config *ssh.ServerConfig) { // Before beginning a handshake must be performed on the incoming net.Conn sconn, chans, reqs, err := ssh.NewServerConn(conn, config) if err != nil { return } defer sconn.Close() - go ssh.DiscardRequests(reqs) - for newChannel := range chans { + for ch := range chans { // If its not a session channel we just move on because its not something we // know how to handle at this point. - if newChannel.ChannelType() != "session" { - newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + if ch.ChannelType() != "session" { + ch.Reject(ssh.UnknownChannelType, "unknown channel type") continue } - channel, requests, err := newChannel.Accept() + channel, requests, err := ch.Accept() if err != nil { continue } - // Channels have a type that is dependent on the protocol. For SFTP this is "subsystem" - // with a payload that (should) be "sftp". Discard anything else we receive ("pty", "shell", etc) go func(in <-chan *ssh.Request) { for req := range in { - ok := false - - switch req.Type { - case "subsystem": - if string(req.Payload[4:]) == "sftp" { - ok = true - } - } - - req.Reply(ok, nil) + // Channels have a type that is dependent on the protocol. For SFTP + // this is "subsystem" with a payload that (should) be "sftp". Discard + // anything else we receive ("pty", "shell", etc) + req.Reply(req.Type == "subsystem" && string(req.Payload[4:]) == "sftp", nil) } }(requests) - // Configure the user's home folder for the rest of the request cycle. - if sconn.Permissions.Extensions["uuid"] == "" { + // If no UUID has been set on this inbound request then we can assume we + // have screwed up something in the authentication code. This is a sanity + // check, but should never be encountered (ideally...). + // + // This will also attempt to match a specific server out of the global server + // store and return nil if there is no match. + uuid := sconn.Permissions.Extensions["uuid"] + srv := server.GetServers().Find(func(s *server.Server) bool { + if uuid == "" { + return false + } + return s.Id() == uuid + }) + if srv == nil { continue } - // Create a new handler for the currently logged in user's server. - fs := c.createHandler(sconn) - - // Create the server instance for the channel using the filesystem we created above. - server := sftp.NewRequestServer(channel, fs) - - if err := server.Serve(); err == io.EOF { - server.Close() + // Spin up a SFTP server instance for the authenticated user's server allowing + // them access to the underlying filesystem. + handler := sftp.NewRequestServer(channel, NewHandler(sconn, srv.Filesystem()).Handlers()) + if err := handler.Serve(); err == io.EOF { + handler.Close() } } } -// 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 -// 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 { - p := FileSystem{ - UUID: sc.Permissions.Extensions["uuid"], - Permissions: strings.Split(sc.Permissions.Extensions["permissions"], ","), - ReadOnly: c.Settings.ReadOnly, - Cache: c.cache, - User: c.User, - HasDiskSpace: c.DiskSpaceValidator, - PathValidator: c.PathValidator, - logger: log.WithFields(log.Fields{ - "subsystem": "sftp", - "username": sc.User(), - "ip": sc.RemoteAddr(), - }), - } - - return sftp.Handlers{ - FileGet: p, - FilePut: p, - FileCmd: p, - FileList: p, - } -} - // 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) if err != nil { - return err + return errors.WithStack(err) } - - if err := os.MkdirAll(path.Join(c.Settings.BasePath, ".sftp"), 0755); err != nil { - return err + if err := os.MkdirAll(path.Join(c.BasePath, ".sftp"), 0755); err != nil { + return errors.Wrap(err, "sftp/server: could not create .sftp directory") } - - o, err := os.OpenFile(path.Join(c.Settings.BasePath, ".sftp/id_rsa"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + o, err := os.OpenFile(path.Join(c.BasePath, ".sftp/id_rsa"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { - return err + return errors.WithStack(err) } defer o.Close() - pkey := &pem.Block{ + err = pem.Encode(o, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), - } - - if err := pem.Encode(o, pkey); err != nil { - return err - } - - return nil + }) + return errors.WithStack(err) +} + +// A function capable of validating user credentials with the Panel API. +func (c *SFTPServer) passwordCallback(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + request := api.SftpAuthRequest{ + User: conn.User(), + Pass: string(pass), + IP: conn.RemoteAddr().String(), + SessionID: conn.SessionID(), + ClientVersion: conn.ClientVersion(), + } + + logger := log.WithFields(log.Fields{"subsystem": "sftp", "username": conn.User(), "ip": conn.RemoteAddr().String()}) + logger.Debug("validating credentials for SFTP connection") + + resp, err := api.New().ValidateSftpCredentials(request) + if err != nil { + if api.IsInvalidCredentialsError(err) { + logger.Warn("failed to validate user credentials (invalid username or password)") + } else { + logger.WithField("error", err).Error("encountered an error while trying to validate user credentials") + } + return nil, err + } + + logger.WithField("server", resp.Server).Debug("credentials validated and matched to server instance") + sshPerm := &ssh.Permissions{ + Extensions: map[string]string{ + "uuid": resp.Server, + "user": conn.User(), + "permissions": strings.Join(resp.Permissions, ","), + }, + } + + return sshPerm, nil } diff --git a/sftp/sftp.go b/sftp/sftp.go deleted file mode 100644 index 995db58..0000000 --- a/sftp/sftp.go +++ /dev/null @@ -1,97 +0,0 @@ -package sftp - -import ( - "emperror.dev/errors" - "github.com/apex/log" - "github.com/pterodactyl/wings/api" - "github.com/pterodactyl/wings/config" - "github.com/pterodactyl/wings/server" -) - -var noMatchingServerError = errors.New("no matching server with that UUID was found") - -func Initialize(config config.SystemConfiguration) error { - s := &Server{ - User: User{ - Uid: config.User.Uid, - Gid: config.User.Gid, - }, - Settings: Settings{ - BasePath: config.Data, - ReadOnly: config.Sftp.ReadOnly, - BindAddress: config.Sftp.Address, - BindPort: config.Sftp.Port, - }, - 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 - // a long running operation. - go func(s *Server) { - if err := s.Initialize(); err != nil { - log.WithField("subsystem", "sftp").WithField("error", err).Error("failed to initialize SFTP subsystem") - } - }(s) - - 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 -// the server's UUID if the credentials were valid. -func validateCredentials(c api.SftpAuthRequest) (*api.SftpAuthResponse, error) { - f := log.Fields{"subsystem": "sftp", "username": c.User, "ip": c.IP} - - log.WithFields(f).Debug("validating credentials for SFTP connection") - resp, err := api.New().ValidateSftpCredentials(c) - if err != nil { - if api.IsInvalidCredentialsError(err) { - log.WithFields(f).Warn("failed to validate user credentials (invalid username or password)") - } else { - log.WithFields(f).Error("encountered an error while trying to validate user credentials") - } - - return resp, err - } - - s := server.GetServers().Find(func(server *server.Server) bool { - return server.Id() == resp.Server - }) - - if s == nil { - return resp, noMatchingServerError - } - - s.Log().WithFields(f).Debug("credentials successfully validated and matched user to server instance") - - return resp, err -} diff --git a/sftp/lister.go b/sftp/utils.go similarity index 57% rename from sftp/lister.go rename to sftp/utils.go index 129020a..5dad454 100644 --- a/sftp/lister.go +++ b/sftp/utils.go @@ -5,6 +5,13 @@ import ( "os" ) +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) +) + type ListerAt []os.FileInfo // Returns the number of entries copied and an io.EOF error if we made it to the end of the file list. @@ -20,3 +27,14 @@ func (l ListerAt) ListAt(f []os.FileInfo, offset int64) (int, error) { return n, nil } } + +type fxerr uint32 + +func (e fxerr) Error() string { + switch e { + case ErrSSHQuotaExceeded: + return "Quota Exceeded" + default: + return "Failure" + } +}