Merge branch 'dane/sftp-cleanup' into develop
This commit is contained in:
commit
e0cf18299a
|
@ -62,12 +62,6 @@ func (r *Request) ValidateSftpCredentials(request SftpAuthRequest) (*SftpAuthRes
|
||||||
e := resp.Error()
|
e := resp.Error()
|
||||||
if e != nil {
|
if e != nil {
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
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{}
|
return nil, &sftpInvalidCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
// Wait until all of the servers are ready to go before we fire up the SFTP and HTTP servers.
|
||||||
pool.StopWait()
|
pool.StopWait()
|
||||||
|
|
||||||
// Initialize the SFTP server.
|
// Run the SFTP server.
|
||||||
if err := sftp.Initialize(c.System); err != nil {
|
if err := sftp.New().Run(); err != nil {
|
||||||
log.WithError(err).Fatal("failed to initialize the sftp server")
|
log.WithError(err).Fatal("failed to initialize the sftp server")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.ConsoleOutputEvent, &console)
|
||||||
s.Environment.Events().On(environment.StateChangeEvent, &state)
|
s.Environment.Events().On(environment.StateChangeEvent, &state)
|
||||||
s.Environment.Events().On(environment.ResourceEvent, &stats)
|
s.Environment.Events().On(environment.ResourceEvent, &stats)
|
||||||
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
418
sftp/handler.go
418
sftp/handler.go
|
@ -5,31 +5,17 @@ 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/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 (
|
const (
|
||||||
PermissionFileRead = "file.read"
|
PermissionFileRead = "file.read"
|
||||||
PermissionFileReadContent = "file.read-content"
|
PermissionFileReadContent = "file.read-content"
|
||||||
|
@ -38,343 +24,269 @@ const (
|
||||||
PermissionFileDelete = "file.delete"
|
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.
|
// 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
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return f, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
// Confirm the user has permission to perform this action BEFORE calling Touch, otherwise
|
||||||
file, err := os.Create(p)
|
// 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
|
||||||
|
}
|
||||||
|
f, err := h.fs.Touch(request.Filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC)
|
||||||
if err != nil {
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// 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 {
|
||||||
l.WithField("target", target).WithField("error", err).Error("failed to create symlink")
|
return sftp.ErrSSHFxNoSuchFile
|
||||||
|
}
|
||||||
return sftp.ErrSshFxFailure
|
target, err := h.fs.SafePath(request.Target)
|
||||||
}
|
if err != nil {
|
||||||
|
return sftp.ErrSSHFxNoSuchFile
|
||||||
break
|
}
|
||||||
case "Remove":
|
if err := os.Symlink(source, target); err != nil {
|
||||||
if !fs.can(PermissionFileDelete) {
|
l.WithField("target", target).WithField("error", err).Error("failed to create symlink")
|
||||||
return sftp.ErrSshFxPermissionDenied
|
return sftp.ErrSSHFxFailure
|
||||||
}
|
}
|
||||||
|
break
|
||||||
if err := os.Remove(p); err != nil {
|
// Called when deleting a file.
|
||||||
if os.IsNotExist(err) {
|
case "Remove":
|
||||||
|
if !h.can(PermissionFileDelete) {
|
||||||
|
return sftp.ErrSSHFxPermissionDenied
|
||||||
|
}
|
||||||
|
if err := h.fs.Delete(request.Filepath); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
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")
|
||||||
s, err := os.Stat(p)
|
return nil, sftp.ErrSSHFxFailure
|
||||||
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{st.Info}), nil
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
337
sftp/server.go
337
sftp/server.go
|
@ -5,74 +5,185 @@ import (
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"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/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Settings struct {
|
//goland:noinspection GoNameStartsWithPackageName
|
||||||
|
type SFTPServer struct {
|
||||||
BasePath string
|
BasePath string
|
||||||
ReadOnly bool
|
ReadOnly bool
|
||||||
BindPort int
|
Listen string
|
||||||
BindAddress string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
func New() *SFTPServer {
|
||||||
Uid int
|
cfg := config.Get().System
|
||||||
Gid int
|
return &SFTPServer{
|
||||||
|
BasePath: cfg.Data,
|
||||||
|
ReadOnly: cfg.Sftp.ReadOnly,
|
||||||
|
Listen: cfg.Sftp.Address + ":" + strconv.Itoa(cfg.Sftp.Port),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
// Starts the SFTP server and add a persistent listener to handle inbound SFTP connections.
|
||||||
cache *cache.Cache
|
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 errors.Wrap(err, "sftp/server: could not stat private key file")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
Settings Settings
|
conf := &ssh.ServerConfig{
|
||||||
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,
|
NoClientAuth: false,
|
||||||
MaxAuthTries: 6,
|
MaxAuthTries: 6,
|
||||||
PasswordCallback: func(conn ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
PasswordCallback: c.passwordCallback,
|
||||||
resp, err := c.CredentialValidator(api.SftpAuthRequest{
|
}
|
||||||
|
conf.AddHostKey(private)
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", c.Listen)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithField("listen", c.Listen).Info("sftp server listening for connections")
|
||||||
|
for {
|
||||||
|
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 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 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 ch.ChannelType() != "session" {
|
||||||
|
ch.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
channel, requests, err := ch.Accept()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(in <-chan *ssh.Request) {
|
||||||
|
for req := range in {
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates a private key that will be used by the SFTP server.
|
||||||
|
func (c *SFTPServer) generatePrivateKey() error {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(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.BasePath, ".sftp/id_rsa"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WithStack(err)
|
||||||
|
}
|
||||||
|
defer o.Close()
|
||||||
|
|
||||||
|
err = pem.Encode(o, &pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
})
|
||||||
|
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(),
|
User: conn.User(),
|
||||||
Pass: string(pass),
|
Pass: string(pass),
|
||||||
IP: conn.RemoteAddr().String(),
|
IP: conn.RemoteAddr().String(),
|
||||||
SessionID: conn.SessionID(),
|
SessionID: conn.SessionID(),
|
||||||
ClientVersion: conn.ClientVersion(),
|
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 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.WithField("server", resp.Server).Debug("credentials validated and matched to server instance")
|
||||||
sshPerm := &ssh.Permissions{
|
sshPerm := &ssh.Permissions{
|
||||||
Extensions: map[string]string{
|
Extensions: map[string]string{
|
||||||
"uuid": resp.Server,
|
"uuid": resp.Server,
|
||||||
|
@ -82,158 +193,4 @@ func (c *Server) Initialize() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return sshPerm, nil
|
return sshPerm, nil
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path.Join(c.Settings.BasePath, ".sftp/id_rsa")); os.IsNotExist(err) {
|
|
||||||
if err := c.generatePrivateKey(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
privateBytes, err := ioutil.ReadFile(path.Join(c.Settings.BasePath, ".sftp/id_rsa"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
private, err := ssh.ParsePrivateKey(privateBytes)
|
|
||||||
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")
|
|
||||||
|
|
||||||
for {
|
|
||||||
conn, _ := listener.Accept()
|
|
||||||
if conn != nil {
|
|
||||||
go c.AcceptInboundConnection(conn, serverConfig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// 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")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
channel, requests, err := newChannel.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)
|
|
||||||
}
|
|
||||||
}(requests)
|
|
||||||
|
|
||||||
// Configure the user's home folder for the rest of the request cycle.
|
|
||||||
if sconn.Permissions.Extensions["uuid"] == "" {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(path.Join(c.Settings.BasePath, ".sftp"), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
o, err := os.OpenFile(path.Join(c.Settings.BasePath, ".sftp/id_rsa"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer o.Close()
|
|
||||||
|
|
||||||
pkey := &pem.Block{
|
|
||||||
Type: "RSA PRIVATE KEY",
|
|
||||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := pem.Encode(o, pkey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
97
sftp/sftp.go
97
sftp/sftp.go
|
@ -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
|
|
||||||
}
|
|
|
@ -5,6 +5,13 @@ import (
|
||||||
"os"
|
"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
|
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.
|
// 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
|
return n, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fxerr uint32
|
||||||
|
|
||||||
|
func (e fxerr) Error() string {
|
||||||
|
switch e {
|
||||||
|
case ErrSSHQuotaExceeded:
|
||||||
|
return "Quota Exceeded"
|
||||||
|
default:
|
||||||
|
return "Failure"
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user