mirror of
https://github.com/rclone/rclone.git
synced 2024-12-01 12:55:19 +01:00
588 lines
14 KiB
Go
588 lines
14 KiB
Go
//go:build !plan9
|
|
|
|
// Package ftp implements an FTP server for rclone
|
|
package ftp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
iofs "io/fs"
|
|
"net"
|
|
"os"
|
|
"os/user"
|
|
"regexp"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/cmd"
|
|
"github.com/rclone/rclone/cmd/serve/proxy"
|
|
"github.com/rclone/rclone/cmd/serve/proxy/proxyflags"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/accounting"
|
|
"github.com/rclone/rclone/fs/config/flags"
|
|
"github.com/rclone/rclone/fs/config/obscure"
|
|
"github.com/rclone/rclone/fs/log"
|
|
"github.com/rclone/rclone/vfs"
|
|
"github.com/rclone/rclone/vfs/vfscommon"
|
|
"github.com/rclone/rclone/vfs/vfsflags"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
ftp "goftp.io/server/v2"
|
|
)
|
|
|
|
// OptionsInfo descripts the Options in use
|
|
var OptionsInfo = fs.Options{{
|
|
Name: "addr",
|
|
Default: "localhost:2121",
|
|
Help: "IPaddress:Port or :Port to bind server to",
|
|
}, {
|
|
Name: "public_ip",
|
|
Default: "",
|
|
Help: "Public IP address to advertise for passive connections",
|
|
}, {
|
|
Name: "passive_port",
|
|
Default: "30000-32000",
|
|
Help: "Passive port range to use",
|
|
}, {
|
|
Name: "user",
|
|
Default: "anonymous",
|
|
Help: "User name for authentication",
|
|
}, {
|
|
Name: "pass",
|
|
Default: "",
|
|
Help: "Password for authentication (empty value allow every password)",
|
|
}, {
|
|
Name: "cert",
|
|
Default: "",
|
|
Help: "TLS PEM key (concatenation of certificate and CA certificate)",
|
|
}, {
|
|
Name: "key",
|
|
Default: "",
|
|
Help: "TLS PEM Private key",
|
|
}}
|
|
|
|
// Options contains options for the http Server
|
|
type Options struct {
|
|
//TODO add more options
|
|
ListenAddr string `config:"addr"` // Port to listen on
|
|
PublicIP string `config:"public_ip"` // Passive ports range
|
|
PassivePorts string `config:"passive_port"` // Passive ports range
|
|
BasicUser string `config:"user"` // single username for basic auth if not using Htpasswd
|
|
BasicPass string `config:"pass"` // password for BasicUser
|
|
TLSCert string `config:"cert"` // TLS PEM key (concatenation of certificate and CA certificate)
|
|
TLSKey string `config:"key"` // TLS PEM Private key
|
|
}
|
|
|
|
// Opt is options set by command line flags
|
|
var Opt Options
|
|
|
|
// AddFlags adds flags for ftp
|
|
func AddFlags(flagSet *pflag.FlagSet) {
|
|
flags.AddFlagsFromOptions(flagSet, "", OptionsInfo)
|
|
}
|
|
|
|
func init() {
|
|
vfsflags.AddFlags(Command.Flags())
|
|
proxyflags.AddFlags(Command.Flags())
|
|
AddFlags(Command.Flags())
|
|
}
|
|
|
|
// Command definition for cobra
|
|
var Command = &cobra.Command{
|
|
Use: "ftp remote:path",
|
|
Short: `Serve remote:path over FTP.`,
|
|
Long: `Run a basic FTP server to serve a remote over FTP protocol.
|
|
This can be viewed with a FTP client or you can make a remote of
|
|
type FTP to read and write it.
|
|
|
|
### Server options
|
|
|
|
Use --addr to specify which IP address and port the server should
|
|
listen on, e.g. --addr 1.2.3.4:8000 or --addr :8080 to listen to all
|
|
IPs. By default it only listens on localhost. You can use port
|
|
:0 to let the OS choose an available port.
|
|
|
|
If you set --addr to listen on a public or LAN accessible IP address
|
|
then using Authentication is advised - see the next section for info.
|
|
|
|
#### Authentication
|
|
|
|
By default this will serve files without needing a login.
|
|
|
|
You can set a single username and password with the --user and --pass flags.
|
|
|
|
` + vfs.Help() + proxy.Help,
|
|
Annotations: map[string]string{
|
|
"versionIntroduced": "v1.44",
|
|
"groups": "Filter",
|
|
},
|
|
Run: func(command *cobra.Command, args []string) {
|
|
var f fs.Fs
|
|
if proxyflags.Opt.AuthProxy == "" {
|
|
cmd.CheckArgs(1, 1, command, args)
|
|
f = cmd.NewFsSrc(args)
|
|
} else {
|
|
cmd.CheckArgs(0, 0, command, args)
|
|
}
|
|
cmd.Run(false, false, command, func() error {
|
|
s, err := newServer(context.Background(), f, &Opt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.serve()
|
|
})
|
|
},
|
|
}
|
|
|
|
// driver contains everything to run the driver for the FTP server
|
|
type driver struct {
|
|
f fs.Fs
|
|
srv *ftp.Server
|
|
ctx context.Context // for global config
|
|
opt Options
|
|
globalVFS *vfs.VFS // the VFS if not using auth proxy
|
|
proxy *proxy.Proxy // may be nil if not in use
|
|
useTLS bool
|
|
userPassMu sync.Mutex // to protect userPass
|
|
userPass map[string]string // cache of username => password when using vfs proxy
|
|
}
|
|
|
|
func init() {
|
|
fs.RegisterGlobalOptions(fs.OptionsInfo{Name: "ftp", Opt: &Opt, Options: OptionsInfo})
|
|
}
|
|
|
|
var passivePortsRe = regexp.MustCompile(`^\s*\d+\s*-\s*\d+\s*$`)
|
|
|
|
// Make a new FTP to serve the remote
|
|
func newServer(ctx context.Context, f fs.Fs, opt *Options) (*driver, error) {
|
|
host, port, err := net.SplitHostPort(opt.ListenAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse host:port from %q", opt.ListenAddr)
|
|
}
|
|
portNum, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse port number from %q", port)
|
|
}
|
|
|
|
d := &driver{
|
|
f: f,
|
|
ctx: ctx,
|
|
opt: *opt,
|
|
}
|
|
if proxyflags.Opt.AuthProxy != "" {
|
|
d.proxy = proxy.New(ctx, &proxyflags.Opt)
|
|
d.userPass = make(map[string]string, 16)
|
|
} else {
|
|
d.globalVFS = vfs.New(f, &vfscommon.Opt)
|
|
}
|
|
d.useTLS = d.opt.TLSKey != ""
|
|
|
|
// Check PassivePorts format since the server library doesn't!
|
|
if !passivePortsRe.MatchString(opt.PassivePorts) {
|
|
return nil, fmt.Errorf("invalid format for passive ports %q", opt.PassivePorts)
|
|
}
|
|
|
|
ftpopt := &ftp.Options{
|
|
Name: "Rclone FTP Server",
|
|
WelcomeMessage: "Welcome to Rclone " + fs.Version + " FTP Server",
|
|
Driver: d,
|
|
Hostname: host,
|
|
Port: portNum,
|
|
PublicIP: opt.PublicIP,
|
|
PassivePorts: opt.PassivePorts,
|
|
Auth: d,
|
|
Perm: ftp.NewSimplePerm("ftp", "ftp"), // fake user and group
|
|
Logger: &Logger{},
|
|
TLS: d.useTLS,
|
|
CertFile: d.opt.TLSCert,
|
|
KeyFile: d.opt.TLSKey,
|
|
//TODO implement a maximum of https://godoc.org/goftp.io/server#ServerOpts
|
|
}
|
|
d.srv, err = ftp.NewServer(ftpopt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create new FTP server: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// serve runs the ftp server
|
|
func (d *driver) serve() error {
|
|
fs.Logf(d.f, "Serving FTP on %s", d.srv.Hostname+":"+strconv.Itoa(d.srv.Port))
|
|
return d.srv.ListenAndServe()
|
|
}
|
|
|
|
// close stops the ftp server
|
|
//
|
|
//lint:ignore U1000 unused when not building linux
|
|
func (d *driver) close() error {
|
|
fs.Logf(d.f, "Stopping FTP on %s", d.srv.Hostname+":"+strconv.Itoa(d.srv.Port))
|
|
return d.srv.Shutdown()
|
|
}
|
|
|
|
// Logger ftp logger output formatted message
|
|
type Logger struct{}
|
|
|
|
// Print log simple text message
|
|
func (l *Logger) Print(sessionID string, message interface{}) {
|
|
fs.Infof(sessionID, "%s", message)
|
|
}
|
|
|
|
// Printf log formatted text message
|
|
func (l *Logger) Printf(sessionID string, format string, v ...interface{}) {
|
|
fs.Infof(sessionID, format, v...)
|
|
}
|
|
|
|
// PrintCommand log formatted command execution
|
|
func (l *Logger) PrintCommand(sessionID string, command string, params string) {
|
|
if command == "PASS" {
|
|
fs.Infof(sessionID, "> PASS ****")
|
|
} else {
|
|
fs.Infof(sessionID, "> %s %s", command, params)
|
|
}
|
|
}
|
|
|
|
// PrintResponse log responses
|
|
func (l *Logger) PrintResponse(sessionID string, code int, message string) {
|
|
fs.Infof(sessionID, "< %d %s", code, message)
|
|
}
|
|
|
|
// CheckPasswd handle auth based on configuration
|
|
func (d *driver) CheckPasswd(sctx *ftp.Context, user, pass string) (ok bool, err error) {
|
|
if d.proxy != nil {
|
|
_, _, err = d.proxy.Call(user, pass, false)
|
|
if err != nil {
|
|
fs.Infof(nil, "proxy login failed: %v", err)
|
|
return false, nil
|
|
}
|
|
// Cache obscured password for later lookup.
|
|
//
|
|
// We don't cache the VFS directly in the driver as we want them
|
|
// to be expired and the auth proxy does that for us.
|
|
oPass, err := obscure.Obscure(pass)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
d.userPassMu.Lock()
|
|
d.userPass[user] = oPass
|
|
d.userPassMu.Unlock()
|
|
} else {
|
|
ok = d.opt.BasicUser == user && (d.opt.BasicPass == "" || d.opt.BasicPass == pass)
|
|
if !ok {
|
|
fs.Infof(nil, "login failed: bad credentials")
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Get the VFS for this connection
|
|
func (d *driver) getVFS(sctx *ftp.Context) (VFS *vfs.VFS, err error) {
|
|
if d.proxy == nil {
|
|
// If no proxy always use the same VFS
|
|
return d.globalVFS, nil
|
|
}
|
|
user := sctx.Sess.LoginUser()
|
|
d.userPassMu.Lock()
|
|
oPass, ok := d.userPass[user]
|
|
d.userPassMu.Unlock()
|
|
if !ok {
|
|
return nil, fmt.Errorf("proxy user not logged in")
|
|
}
|
|
pass, err := obscure.Reveal(oPass)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
VFS, _, err = d.proxy.Call(user, pass, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("proxy login failed: %w", err)
|
|
}
|
|
return VFS, nil
|
|
}
|
|
|
|
// Stat get information on file or folder
|
|
func (d *driver) Stat(sctx *ftp.Context, path string) (fi iofs.FileInfo, err error) {
|
|
defer log.Trace(path, "")("fi=%+v, err = %v", &fi, &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &FileInfo{n, n.Mode(), VFS.Opt.UID, VFS.Opt.GID}, err
|
|
}
|
|
|
|
// ChangeDir move current folder
|
|
func (d *driver) ChangeDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !n.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListDir list content of a folder
|
|
func (d *driver) ListDir(sctx *ftp.Context, path string, callback func(iofs.FileInfo) error) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err == vfs.ENOENT {
|
|
return errors.New("directory not found")
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
|
|
dir := node.(*vfs.Dir)
|
|
dirEntries, err := dir.ReadDirAll()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Account the transfer
|
|
tr := accounting.GlobalStats().NewTransferRemoteSize(path, node.Size(), d.f, nil)
|
|
defer func() {
|
|
tr.Done(d.ctx, err)
|
|
}()
|
|
|
|
for _, file := range dirEntries {
|
|
err = callback(&FileInfo{file, file.Mode(), VFS.Opt.UID, VFS.Opt.GID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteDir delete a folder and his content
|
|
func (d *driver) DeleteDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsDir() {
|
|
return errors.New("not a directory")
|
|
}
|
|
err = node.Remove()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteFile delete a file
|
|
func (d *driver) DeleteFile(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !node.IsFile() {
|
|
return errors.New("not a file")
|
|
}
|
|
err = node.Remove()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rename rename a file or folder
|
|
func (d *driver) Rename(sctx *ftp.Context, oldName, newName string) (err error) {
|
|
defer log.Trace(oldName, "newName=%q", newName)("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return VFS.Rename(oldName, newName)
|
|
}
|
|
|
|
// MakeDir create a folder
|
|
func (d *driver) MakeDir(sctx *ftp.Context, path string) (err error) {
|
|
defer log.Trace(path, "")("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir, leaf, err := VFS.StatParent(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = dir.Mkdir(leaf)
|
|
return err
|
|
}
|
|
|
|
// GetFile download a file
|
|
func (d *driver) GetFile(sctx *ftp.Context, path string, offset int64) (size int64, fr io.ReadCloser, err error) {
|
|
defer log.Trace(path, "offset=%v", offset)("err = %v", &err)
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
node, err := VFS.Stat(path)
|
|
if err == vfs.ENOENT {
|
|
fs.Infof(path, "File not found")
|
|
return 0, nil, errors.New("file not found")
|
|
} else if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if !node.IsFile() {
|
|
return 0, nil, errors.New("not a file")
|
|
}
|
|
|
|
handle, err := node.Open(os.O_RDONLY)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
_, err = handle.Seek(offset, io.SeekStart)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
// Account the transfer
|
|
tr := accounting.GlobalStats().NewTransferRemoteSize(path, node.Size(), d.f, nil)
|
|
defer tr.Done(d.ctx, nil)
|
|
|
|
return node.Size(), handle, nil
|
|
}
|
|
|
|
// PutFile upload a file
|
|
func (d *driver) PutFile(sctx *ftp.Context, path string, data io.Reader, offset int64) (n int64, err error) {
|
|
defer log.Trace(path, "offset=%d", offset)("err = %v", &err)
|
|
|
|
var isExist bool
|
|
VFS, err := d.getVFS(sctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
fi, err := VFS.Stat(path)
|
|
if err == nil {
|
|
isExist = true
|
|
if fi.IsDir() {
|
|
return 0, errors.New("can't create file - directory exists")
|
|
}
|
|
} else {
|
|
if os.IsNotExist(err) {
|
|
isExist = false
|
|
} else {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
if offset > -1 && !isExist {
|
|
offset = -1
|
|
}
|
|
|
|
var f vfs.Handle
|
|
|
|
if offset == -1 {
|
|
if isExist {
|
|
err = VFS.Remove(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
f, err = VFS.Create(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fs.CheckClose(f, &err)
|
|
n, err = io.Copy(f, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
f, err = VFS.OpenFile(path, os.O_APPEND|os.O_RDWR, 0660)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer fs.CheckClose(f, &err)
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if offset > info.Size() {
|
|
return 0, fmt.Errorf("offset %d is beyond file size %d", offset, info.Size())
|
|
}
|
|
|
|
_, err = f.Seek(offset, io.SeekStart)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
bytes, err := io.Copy(f, data)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return bytes, nil
|
|
}
|
|
|
|
// FileInfo struct to hold file info for ftp server
|
|
type FileInfo struct {
|
|
os.FileInfo
|
|
|
|
mode os.FileMode
|
|
owner uint32
|
|
group uint32
|
|
}
|
|
|
|
// Mode return mode of file.
|
|
func (f *FileInfo) Mode() os.FileMode {
|
|
return f.mode
|
|
}
|
|
|
|
// Owner return owner of file. Try to find the username if possible
|
|
func (f *FileInfo) Owner() string {
|
|
str := fmt.Sprint(f.owner)
|
|
u, err := user.LookupId(str)
|
|
if err != nil {
|
|
return str //User not found
|
|
}
|
|
return u.Username
|
|
}
|
|
|
|
// Group return group of file. Try to find the group name if possible
|
|
func (f *FileInfo) Group() string {
|
|
str := fmt.Sprint(f.group)
|
|
g, err := user.LookupGroupId(str)
|
|
if err != nil {
|
|
return str //Group not found default to numerical value
|
|
}
|
|
return g.Name
|
|
}
|
|
|
|
// ModTime returns the time in UTC
|
|
func (f *FileInfo) ModTime() time.Time {
|
|
return f.FileInfo.ModTime().UTC()
|
|
}
|