rclone/cmd/serve/ftp/ftp.go
2024-07-15 11:09:54 +01:00

584 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
}
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, errors.New("failed to parse host:port")
}
portNum, err := strconv.Atoi(port)
if err != nil {
return nil, errors.New("failed to parse host: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()
}