feat: sftp server support (#7643)

* feat: sftp server support

* fix(sftp-server): try fix build failed

* fix: sftp download lack
This commit is contained in:
KirCute_ECT
2024-12-12 20:51:43 +08:00
committed by GitHub
parent 201e25c17f
commit 33ba7f1521
14 changed files with 584 additions and 32 deletions

View File

@ -70,7 +70,7 @@ func NewMainDriver() (*FtpMainDriver, error) {
Banner: setting.GetStr(conf.Announcement),
TLSRequired: tlsRequired,
DisableLISTArgs: false,
DisableSite: true,
DisableSite: false,
DisableActiveMode: conf.Conf.FTP.DisableActiveMode,
EnableHASH: false,
DisableSTAT: false,
@ -79,6 +79,9 @@ func NewMainDriver() (*FtpMainDriver, error) {
DefaultTransferType: transferType,
ActiveConnectionsCheck: activeConnCheck,
PasvConnectionsCheck: pasvConnCheck,
SiteHandlers: map[string]ftpserver.SiteHandler{
"SIZE": ftp.HandleSIZE,
},
},
proxyHeader: header,
clients: make(map[uint32]ftpserver.ClientContext),
@ -128,7 +131,7 @@ func (d *FtpMainDriver) AuthUser(cc ftpserver.ClientContext, user, pass string)
}
}
if userObj.Disabled || !userObj.CanFTPAccess() {
return nil, errors.New("user not allowed to access FTP")
return nil, errors.New("user is not allowed to access via FTP")
}
ctx := context.Background()

View File

@ -5,13 +5,15 @@ import (
"errors"
ftpserver "github.com/KirCute/ftpserverlib-pasvportmap"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/fs"
"github.com/spf13/afero"
"os"
"time"
)
type AferoAdapter struct {
ctx context.Context
ctx context.Context
nextFileSize int64
}
func NewAferoAdapter(ctx context.Context) *AferoAdapter {
@ -78,14 +80,36 @@ func (a *AferoAdapter) ReadDir(name string) ([]os.FileInfo, error) {
}
func (a *AferoAdapter) GetHandle(name string, flags int, offset int64) (ftpserver.FileTransfer, error) {
fileSize := a.nextFileSize
a.nextFileSize = 0
if offset != 0 {
return nil, errors.New("offset")
return nil, errs.NotSupport
}
if (flags & os.O_APPEND) > 0 {
return nil, errors.New("append")
if (flags & os.O_SYNC) != 0 {
return nil, errs.NotSupport
}
if (flags & os.O_WRONLY) > 0 {
return OpenUpload(a.ctx, name)
if (flags & os.O_APPEND) != 0 {
return nil, errs.NotSupport
}
_, err := fs.Get(a.ctx, name, &fs.GetArgs{})
exists := err == nil
if (flags&os.O_CREATE) == 0 && !exists {
return nil, errs.ObjectNotFound
}
if (flags&os.O_EXCL) != 0 && exists {
return nil, errors.New("file already exists")
}
if (flags & os.O_WRONLY) != 0 {
trunc := (flags & os.O_TRUNC) != 0
if fileSize > 0 {
return OpenUploadWithLength(a.ctx, name, trunc, fileSize)
} else {
return OpenUpload(a.ctx, name, trunc)
}
}
return OpenDownload(a.ctx, name)
}
func (a *AferoAdapter) SetNextFileSize(size int64) {
a.nextFileSize = size
}

11
server/ftp/const.go Normal file
View File

@ -0,0 +1,11 @@
package ftp
// From leffss/sftpd
const (
SSH_FXF_READ = 0x00000001
SSH_FXF_WRITE = 0x00000002
SSH_FXF_APPEND = 0x00000004
SSH_FXF_CREAT = 0x00000008
SSH_FXF_TRUNC = 0x00000010
SSH_FXF_EXCL = 0x00000020
)

View File

@ -1,6 +1,7 @@
package ftp
import (
"bytes"
"context"
ftpserver "github.com/KirCute/ftpserverlib-pasvportmap"
"github.com/alist-org/alist/v3/internal/conf"
@ -23,29 +24,38 @@ type FileUploadProxy struct {
buffer *os.File
path string
ctx context.Context
trunc bool
}
func OpenUpload(ctx context.Context, path string) (*FileUploadProxy, error) {
func uploadAuth(ctx context.Context, path string) error {
user := ctx.Value("user").(*model.User)
path, err := user.JoinPath(path)
if err != nil {
return nil, err
return err
}
meta, err := op.GetNearestMeta(stdpath.Dir(path))
if err != nil {
if !errors.Is(errors.Cause(err), errs.MetaNotFound) {
return nil, err
return err
}
}
if !(common.CanAccess(user, meta, path, ctx.Value("meta_pass").(string)) &&
((user.CanFTPManage() && user.CanWrite()) || common.CanWrite(meta, stdpath.Dir(path)))) {
return nil, errs.PermissionDenied
return errs.PermissionDenied
}
return nil
}
func OpenUpload(ctx context.Context, path string, trunc bool) (*FileUploadProxy, error) {
err := uploadAuth(ctx, path)
if err != nil {
return nil, err
}
tmpFile, err := os.CreateTemp(conf.Conf.TempDir, "file-*")
if err != nil {
return nil, err
}
return &FileUploadProxy{buffer: tmpFile, path: path, ctx: ctx}, nil
return &FileUploadProxy{buffer: tmpFile, path: path, ctx: ctx, trunc: trunc}, nil
}
func (f *FileUploadProxy) Read(p []byte) (n int, err error) {
@ -77,6 +87,9 @@ func (f *FileUploadProxy) Close() error {
if _, err := f.buffer.Seek(0, io.SeekStart); err != nil {
return err
}
if f.trunc {
_ = fs.Remove(f.ctx, f.path)
}
s := &stream.FileStream{
Obj: &model.Object{
Name: name,
@ -84,10 +97,113 @@ func (f *FileUploadProxy) Close() error {
Modified: time.Now(),
},
Mimetype: contentType,
WebPutAsTask: false,
WebPutAsTask: true,
}
s.SetTmpFile(f.buffer)
s.Closers.Add(f.buffer)
_, err = fs.PutAsTask(f.ctx, dir, s)
return err
}
type FileUploadWithLengthProxy struct {
ftpserver.FileTransfer
ctx context.Context
path string
length int64
first512Bytes [512]byte
pFirst int
pipeWriter io.WriteCloser
errChan chan error
}
func OpenUploadWithLength(ctx context.Context, path string, trunc bool, length int64) (*FileUploadWithLengthProxy, error) {
err := uploadAuth(ctx, path)
if err != nil {
return nil, err
}
if trunc {
_ = fs.Remove(ctx, path)
}
return &FileUploadWithLengthProxy{ctx: ctx, path: path, length: length}, nil
}
func (f *FileUploadWithLengthProxy) Read(p []byte) (n int, err error) {
return 0, errs.NotSupport
}
func (f *FileUploadWithLengthProxy) Write(p []byte) (n int, err error) {
if f.pipeWriter != nil {
select {
case e := <-f.errChan:
return 0, e
default:
return f.pipeWriter.Write(p)
}
} else if len(p) < 512-f.pFirst {
copy(f.first512Bytes[f.pFirst:], p)
f.pFirst += len(p)
return len(p), nil
} else {
copy(f.first512Bytes[f.pFirst:], p[:512-f.pFirst])
contentType := http.DetectContentType(f.first512Bytes[:])
dir, name := stdpath.Split(f.path)
reader, writer := io.Pipe()
f.errChan = make(chan error, 1)
s := &stream.FileStream{
Obj: &model.Object{
Name: name,
Size: f.length,
Modified: time.Now(),
},
Mimetype: contentType,
WebPutAsTask: false,
Reader: reader,
}
go func() {
e := fs.PutDirectly(f.ctx, dir, s, true)
f.errChan <- e
close(f.errChan)
}()
f.pipeWriter = writer
n, err = writer.Write(f.first512Bytes[:])
if err != nil {
return n, err
}
n1, err := writer.Write(p[512-f.pFirst:])
if err != nil {
return n1 + 512 - f.pFirst, err
}
f.pFirst = 512
return len(p), nil
}
}
func (f *FileUploadWithLengthProxy) Seek(offset int64, whence int) (int64, error) {
return 0, errs.NotSupport
}
func (f *FileUploadWithLengthProxy) Close() error {
if f.pipeWriter != nil {
err := f.pipeWriter.Close()
if err != nil {
return err
}
err = <-f.errChan
return err
} else {
data := f.first512Bytes[:f.pFirst]
contentType := http.DetectContentType(data)
dir, name := stdpath.Split(f.path)
s := &stream.FileStream{
Obj: &model.Object{
Name: name,
Size: int64(f.pFirst),
Modified: time.Now(),
},
Mimetype: contentType,
WebPutAsTask: false,
Reader: bytes.NewReader(data),
}
return fs.PutDirectly(f.ctx, dir, s, true)
}
}

122
server/ftp/sftp.go Normal file
View File

@ -0,0 +1,122 @@
package ftp
import (
"github.com/KirCute/sftpd-alist"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/utils"
"os"
)
type SftpDriverAdapter struct {
FtpDriver *AferoAdapter
}
func (s *SftpDriverAdapter) OpenFile(_ string, _ uint32, _ *sftpd.Attr) (sftpd.File, error) {
// See also GetHandle
return nil, errs.NotImplement
}
func (s *SftpDriverAdapter) OpenDir(_ string) (sftpd.Dir, error) {
// See also GetHandle
return nil, errs.NotImplement
}
func (s *SftpDriverAdapter) Remove(name string) error {
return s.FtpDriver.Remove(name)
}
func (s *SftpDriverAdapter) Rename(old, new string, _ uint32) error {
return s.FtpDriver.Rename(old, new)
}
func (s *SftpDriverAdapter) Mkdir(name string, attr *sftpd.Attr) error {
return s.FtpDriver.Mkdir(name, attr.Mode)
}
func (s *SftpDriverAdapter) Rmdir(name string) error {
return s.Remove(name)
}
func (s *SftpDriverAdapter) Stat(name string, _ bool) (*sftpd.Attr, error) {
stat, err := s.FtpDriver.Stat(name)
if err != nil {
return nil, err
}
return fileInfoToSftpAttr(stat), nil
}
func (s *SftpDriverAdapter) SetStat(_ string, _ *sftpd.Attr) error {
return errs.NotSupport
}
func (s *SftpDriverAdapter) ReadLink(_ string) (string, error) {
return "", errs.NotSupport
}
func (s *SftpDriverAdapter) CreateLink(_, _ string, _ uint32) error {
return errs.NotSupport
}
func (s *SftpDriverAdapter) RealPath(path string) (string, error) {
return utils.FixAndCleanPath(path), nil
}
func (s *SftpDriverAdapter) GetHandle(name string, flags uint32, _ *sftpd.Attr, offset uint64) (sftpd.FileTransfer, error) {
return s.FtpDriver.GetHandle(name, sftpFlagToOpenMode(flags), int64(offset))
}
func (s *SftpDriverAdapter) ReadDir(name string) ([]sftpd.NamedAttr, error) {
dir, err := s.FtpDriver.ReadDir(name)
if err != nil {
return nil, err
}
ret := make([]sftpd.NamedAttr, len(dir))
for i, d := range dir {
ret[i] = *fileInfoToSftpNamedAttr(d)
}
return ret, nil
}
// From leffss/sftpd
func sftpFlagToOpenMode(flags uint32) int {
mode := 0
if (flags & SSH_FXF_READ) != 0 {
mode |= os.O_RDONLY
}
if (flags & SSH_FXF_WRITE) != 0 {
mode |= os.O_WRONLY
}
if (flags & SSH_FXF_APPEND) != 0 {
mode |= os.O_APPEND
}
if (flags & SSH_FXF_CREAT) != 0 {
mode |= os.O_CREATE
}
if (flags & SSH_FXF_TRUNC) != 0 {
mode |= os.O_TRUNC
}
if (flags & SSH_FXF_EXCL) != 0 {
mode |= os.O_EXCL
}
return mode
}
func fileInfoToSftpAttr(stat os.FileInfo) *sftpd.Attr {
ret := &sftpd.Attr{}
ret.Flags |= sftpd.ATTR_SIZE
ret.Size = uint64(stat.Size())
ret.Flags |= sftpd.ATTR_MODE
ret.Mode = stat.Mode()
ret.Flags |= sftpd.ATTR_TIME
ret.ATime = stat.Sys().(model.Obj).CreateTime()
ret.MTime = stat.ModTime()
return ret
}
func fileInfoToSftpNamedAttr(stat os.FileInfo) *sftpd.NamedAttr {
return &sftpd.NamedAttr{
Name: stat.Name(),
Attr: *fileInfoToSftpAttr(stat),
}
}

21
server/ftp/site.go Normal file
View File

@ -0,0 +1,21 @@
package ftp
import (
"fmt"
ftpserver "github.com/KirCute/ftpserverlib-pasvportmap"
"strconv"
)
func HandleSIZE(param string, client ftpserver.ClientDriver) (int, string) {
fs, ok := client.(*AferoAdapter)
if !ok {
return ftpserver.StatusNotLoggedIn, "Unexpected exception (driver is nil)"
}
size, err := strconv.ParseInt(param, 10, 64)
if err != nil {
return ftpserver.StatusSyntaxErrorParameters, fmt.Sprintf(
"Couldn't parse file size, given: %s, err: %v", param, err)
}
fs.SetNextFileSize(size)
return ftpserver.StatusOK, "Accepted next file size"
}

109
server/sftp.go Normal file
View File

@ -0,0 +1,109 @@
package server
import (
"context"
"github.com/KirCute/sftpd-alist"
"github.com/alist-org/alist/v3/internal/conf"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/internal/op"
"github.com/alist-org/alist/v3/internal/setting"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/server/ftp"
"github.com/pkg/errors"
"golang.org/x/crypto/ssh"
"net/http"
)
type SftpDriver struct {
proxyHeader *http.Header
config *sftpd.Config
}
func NewSftpDriver() (*SftpDriver, error) {
header := &http.Header{}
header.Add("User-Agent", setting.GetStr(conf.FTPProxyUserAgent))
return &SftpDriver{
proxyHeader: header,
}, nil
}
func (d *SftpDriver) GetConfig() *sftpd.Config {
if d.config != nil {
return d.config
}
serverConfig := ssh.ServerConfig{
NoClientAuth: true,
NoClientAuthCallback: d.NoClientAuth,
PasswordCallback: d.PasswordAuth,
AuthLogCallback: d.AuthLogCallback,
BannerCallback: d.GetBanner,
}
for _, k := range conf.SSHSigners {
serverConfig.AddHostKey(k)
}
d.config = &sftpd.Config{
ServerConfig: serverConfig,
HostPort: conf.Conf.SFTP.Listen,
ErrorLogFunc: utils.Log.Error,
//DebugLogFunc: utils.Log.Debugf,
}
return d.config
}
func (d *SftpDriver) GetFileSystem(sc *ssh.ServerConn) (sftpd.FileSystem, error) {
userObj, err := op.GetUserByName(sc.User())
if err != nil {
return nil, err
}
ctx := context.Background()
ctx = context.WithValue(ctx, "user", userObj)
ctx = context.WithValue(ctx, "meta_pass", "")
ctx = context.WithValue(ctx, "client_ip", sc.RemoteAddr().String())
ctx = context.WithValue(ctx, "proxy_header", d.proxyHeader)
return &ftp.SftpDriverAdapter{FtpDriver: ftp.NewAferoAdapter(ctx)}, nil
}
func (d *SftpDriver) Close() {
}
func (d *SftpDriver) NoClientAuth(conn ssh.ConnMetadata) (*ssh.Permissions, error) {
if conn.User() != "guest" {
return nil, errors.New("only guest is allowed to login without authorization")
}
guest, err := op.GetGuest()
if err != nil {
return nil, err
}
if guest.Disabled || !guest.CanFTPAccess() {
return nil, errors.New("user is not allowed to access via SFTP")
}
return nil, nil
}
func (d *SftpDriver) PasswordAuth(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
userObj, err := op.GetUserByName(conn.User())
if err != nil {
return nil, err
}
passHash := model.StaticHash(string(password))
if err = userObj.ValidatePwdStaticHash(passHash); err != nil {
return nil, err
}
if userObj.Disabled || !userObj.CanFTPAccess() {
return nil, errors.New("user is not allowed to access via SFTP")
}
return nil, nil
}
func (d *SftpDriver) AuthLogCallback(conn ssh.ConnMetadata, method string, err error) {
ip := conn.RemoteAddr().String()
if err == nil {
utils.Log.Infof("[SFTP] %s(%s) logged in via %s", conn.User(), ip, method)
} else if method != "none" {
utils.Log.Infof("[SFTP] %s(%s) tries logging in via %s but with error: %s", conn.User(), ip, method, err)
}
}
func (d *SftpDriver) GetBanner(_ ssh.ConnMetadata) string {
return setting.GetStr(conf.Announcement)
}