diff --git a/README.md b/README.md index 1c4224a7..2347e931 100644 --- a/README.md +++ b/README.md @@ -173,26 +173,9 @@ More in-depth analysis of performance can be found [here](./docs/performance.md) ## Acknowledgements -- [pkg/sftp](https://github.com/pkg/sftp) -- [go-chi](https://github.com/go-chi/chi) -- [zerolog](https://github.com/rs/zerolog) -- [lumberjack](https://gopkg.in/natefinch/lumberjack.v2) -- [argon2id](https://github.com/alexedwards/argon2id) -- [go-sqlite3](https://github.com/mattn/go-sqlite3) -- [go-sql-driver/mysql](https://github.com/go-sql-driver/mysql) -- [bbolt](https://github.com/etcd-io/bbolt) -- [lib/pq](https://github.com/lib/pq) -- [viper](https://github.com/spf13/viper) -- [cobra](https://github.com/spf13/cobra) -- [xid](https://github.com/rs/xid) -- [nathanaelle/password](https://github.com/nathanaelle/password) -- [PipeAt](https://github.com/eikenb/pipeat) -- [ZeroConf](https://github.com/grandcat/zeroconf) -- [SB Admin 2](https://github.com/BlackrockDigital/startbootstrap-sb-admin-2) -- [shlex](https://github.com/google/shlex) -- [go-proxyproto](https://github.com/pires/go-proxyproto) - -Some code was initially taken from [Pterodactyl sftp server](https://github.com/pterodactyl/sftp-server) +SFTPGo makes use of the third party libraries listed inside [go.mod](./go.mod). +Some code was initially taken from [Pterodactyl SFTP Server](https://github.com/pterodactyl/sftp-server). +We are very grateful to all the people who contributed with ideas and/or pull requests. ## License diff --git a/docker/sftpgo/debian/Dockerfile b/docker/sftpgo/debian/Dockerfile index 5e6ef93e..f602991e 100644 --- a/docker/sftpgo/debian/Dockerfile +++ b/docker/sftpgo/debian/Dockerfile @@ -16,7 +16,7 @@ FROM debian:latest RUN apt-get update && apt-get install -y ca-certificates # git and rsync are optional, uncomment the next line to add support for them if needed. -#RUN apt-get update && apt-get install -y git rsync ca-certificates +#RUN apt-get update && apt-get install -y git rsync ARG BASE_DIR=/app ARG DATA_REL_DIR=data diff --git a/docs/custom-actions.md b/docs/custom-actions.md index 0d7f6ab8..7e46c23b 100644 --- a/docs/custom-actions.md +++ b/docs/custom-actions.md @@ -12,7 +12,7 @@ If the `hook` defines a path to an external program, then this program is invoke - `action`, string, possible values are: `download`, `upload`, `pre-delete`,`delete`, `rename`, `ssh_cmd` - `username` - `path` is the full filesystem path, can be empty for some ssh commands -- `target_path`, non-empty for `rename` action +- `target_path`, non-empty for `rename` action and for `sftpgo-copy` SSH command - `ssh_cmd`, non-empty for `ssh_cmd` action The external program can also read the following environment variables: diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 6bb50bbc..842a37c6 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -60,12 +60,7 @@ The configuration file contains the following sections: - `trusted_user_ca_keys`, list of public keys paths of certificate authorities that are trusted to sign user certificates for authentication. The paths can be absolute or relative to the configuration directory. - `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner. - `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored. - - `enabled_ssh_commands`, list of enabled SSH commands. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`, `scp`. `*` enables all supported commands. Some commands are implemented directly inside SFTPGo, while for other commands we use system commands that need to be installed and in your system's `PATH`. For system commands we have no direct control on file creation/deletion and so we cannot support virtual folders, cloud storage filesystem, such as S3, and quota check is suboptimal: if quota is enabled, the number of files is checked at the command start and not while new files are created. The allowed size is calculated as the difference between the max quota and the used one, and it is checked against the bytes transferred via SSH. The command is aborted if it uploads more bytes than the remaining allowed size calculated at the command start. Anyway, we see the bytes that the remote command sends to the local command via SSH. These bytes contain both protocol commands and files, and so the size of the files is different from the size trasferred via SSH: for example, a command can send compressed files, or a protocol command (few bytes) could delete a big file. To mitigate this issue, quotas are recalculated at the command end with a full home directory scan. This could be heavy for big directories. If you need system commands and quotas you could consider disabling quota restrictions and periodically update quota usage yourself using the REST API. We support the following SSH commands: - - `scp`, we have our own SCP implementation since we can't rely on `scp` system command to proper handle quotas, user's home dir restrictions, cloud storage providers and virtual folders. SCP between two remote hosts is supported using the `-3` scp option. - - `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example, on Windows. - - `cd`, `pwd`. Some SFTP clients do not support the SFTP SSH_FXP_REALPATH packet type, so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` does nothing and `pwd` always returns the `/` path. - - `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH. They need to be installed and in your system's `PATH`. Git commands are not allowed inside virtual folders or inside directories with file extensions filters. - - `rsync`. The `rsync` command needs to be installed and in your system's `PATH`. We cannot avoid that rsync creates symlinks, so if the user has the permission to create symlinks, we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent creating symlinks that point outside the home dir. If the user cannot create symlinks, we add the option `--munge-links` if it is not already set. This should make symlinks unusable (but manually recoverable). The `rsync` command interacts with the filesystem directly and it is not aware of virtual folders and file extensions filters, so it will be automatically disabled for users with these features enabled. + - `enabled_ssh_commands`, list of enabled SSH commands. `*` enables all supported commands. More information can be found [here](./ssh-commands.md). - `keyboard_interactive_auth_program`, string. Deprecated, please use `keyboard_interactive_auth_hook`. - `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details. - `proxy_protocol`, integer. Support for [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable the proxy protocol. It provides a convenient way to safely transport connection information such as a client's address across multiple layers of NAT or TCP proxies to get the real client IP address instead of the proxy IP. Both protocol versions 1 and 2 are supported. If the proxy protocol is enabled in SFTPGo then you have to enable the protocol in your proxy configuration too. For example, for HAProxy, add `send-proxy` or `send-proxy-v2` to each server configuration line. The following modes are supported: diff --git a/docs/ssh-commands.md b/docs/ssh-commands.md new file mode 100644 index 00000000..9bca1b16 --- /dev/null +++ b/docs/ssh-commands.md @@ -0,0 +1,21 @@ +# SSH commands + +Some SSH commands are implemented directly inside SFTPGo, while for other commands we use system commands that need to be installed and in your system's `PATH`. For system commands we have no direct control on file creation/deletion and so we cannot support virtual folders, cloud storage filesystem, such as S3, and quota check is suboptimal. If quota is enabled, the number of files is checked at the command start and not while new files are created. The allowed size is calculated as the difference between the max quota and the used one, and it is checked against the bytes transferred via SSH. The command is aborted if it uploads more bytes than the remaining allowed size calculated at the command start. Anyway, we see the bytes that the remote command sends to the local command via SSH. These bytes contain both protocol commands and files, and so the size of the files is different from the size trasferred via SSH: for example, a command can send compressed files, or a protocol command (few bytes) could delete a big file. To mitigate this issue, quotas are recalculated at the command end with a full home directory scan. This could be heavy for big directories. If you need system commands and quotas you could consider disabling quota restrictions and periodically update quota usage yourself using the REST API. + +We support the following SSH commands: + +- `scp`, we have our own SCP implementation since we can't rely on `scp` system command to proper handle quotas, user's home dir restrictions, cloud storage providers and virtual folders. SCP between two remote hosts is supported using the `-3` scp option. +- `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example, on Windows. +- `cd`, `pwd`. Some SFTP clients do not support the SFTP SSH_FXP_REALPATH packet type, so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` does nothing and `pwd` always returns the `/` path. +- `git-receive-pack`, `git-upload-pack`, `git-upload-archive`. These commands enable support for Git repositories over SSH. They need to be installed and in your system's `PATH`. Git commands are not allowed inside virtual folders or inside directories with file extensions filters. +- `rsync`. The `rsync` command needs to be installed and in your system's `PATH`. We cannot avoid that rsync creates symlinks, so if the user has the permission to create symlinks, we add the option `--safe-links` to the received rsync command if it is not already set. This should prevent creating symlinks that point outside the home dir. If the user cannot create symlinks, we add the option `--munge-links` if it is not already set. This should make symlinks unusable (but manually recoverable). The `rsync` command interacts with the filesystem directly and it is not aware of virtual folders and file extensions filters, so it will be automatically disabled for users with these features enabled. +- `sftpgo-copy`. This is a builtin copy implementation. It allows server side copy for files and directories. The first argument is the source file/directory and the second one is the destination file/directory, for example `sftpgo-copy `. The command will fail if the destination directory exists. Copy for directories spanning virtual folders is not supported. +- `sftpgo-remove`. This is a builtin remove implementation. It allows to remove files and directory recursively. The first argument is the file/directory to remove, for example `sftpgo-remove `. + +The following SSH commands are enabled by default: + +- `md5sum` +- `sha1sum` +- `cd` +- `pwd` +- `scp` \ No newline at end of file diff --git a/go.mod b/go.mod index 6a7fd688..87e5ddc9 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/miekg/dns v1.1.29 // indirect github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/nathanaelle/password/v2 v2.0.1 + github.com/otiai10/copy v1.2.0 github.com/pelletier/go-toml v1.7.0 // indirect github.com/pires/go-proxyproto v0.1.3 github.com/pkg/sftp v1.11.1-0.20200310224833-18dc4db7a456 diff --git a/go.sum b/go.sum index 1819523c..daaf4fdb 100644 --- a/go.sum +++ b/go.sum @@ -186,6 +186,12 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/nathanaelle/password/v2 v2.0.1 h1:ItoCTdsuIWzilYmllQPa3DR3YoCXcpfxScWLqr8Ii2s= github.com/nathanaelle/password/v2 v2.0.1/go.mod h1:eaoT+ICQEPNtikBRIAatN8ThWwMhVG+r1jTw60BvPJk= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/otiai10/copy v1.2.0 h1:HvG945u96iNadPoG2/Ja2+AUJeW5YuFQMixq9yirC+k= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index c66a38e8..1056c3f7 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -712,7 +712,7 @@ func TestSSHCommandErrors(t *testing.T) { err = cmd.handle() assert.Error(t, err, "ssh command must fail, we are requesting an invalid path") - cmd.connection.User.HomeDir = os.TempDir() + cmd.connection.User.HomeDir = filepath.Clean(os.TempDir()) cmd.connection.User.QuotaFiles = 1 cmd.connection.User.UsedQuotaFiles = 2 fs, err = cmd.connection.User.GetFilesystem("123") @@ -755,6 +755,54 @@ func TestSSHCommandErrors(t *testing.T) { assert.NoError(t, err) err = cmd.executeSystemCommand(command) assert.Error(t, err, "command must fail, pipe was already assigned") + + cmd = sshCommand{ + command: "sftpgo-remove", + connection: connection, + args: []string{"/../../src"}, + } + err = cmd.handle() + assert.Error(t, err, "ssh command must fail, we are requesting an invalid path") + + cmd = sshCommand{ + command: "sftpgo-copy", + connection: connection, + args: []string{"/../../test_src", "."}, + } + err = cmd.handle() + assert.Error(t, err, "ssh command must fail, we are requesting an invalid path") + cmd.connection.fs = fs + _, _, err = cmd.resolveCopyPaths(".", "../adir") + assert.Error(t, err) + cmd = sshCommand{ + command: "sftpgo-copy", + connection: connection, + args: []string{"src", "dst"}, + } + cmd.connection.User.Permissions = make(map[string][]string) + cmd.connection.User.Permissions["/"] = []string{dataprovider.PermDownload} + _, _, err = cmd.getCopyPaths() + if assert.Error(t, err) { + assert.EqualError(t, err, errPermissionDenied.Error()) + } + cmd.connection.User.Permissions = make(map[string][]string) + cmd.connection.User.Permissions["/"] = []string{dataprovider.PermAny} + if runtime.GOOS != osWindows { + aDir := filepath.Join(os.TempDir(), "adir") + err = os.MkdirAll(aDir, os.ModePerm) + assert.NoError(t, err) + tmpFile := filepath.Join(aDir, "testcopy") + err = ioutil.WriteFile(tmpFile, []byte("aaa"), os.ModePerm) + assert.NoError(t, err) + err = os.Chmod(aDir, 0001) + assert.NoError(t, err) + err = cmd.checkCopyDestination(tmpFile) + assert.Error(t, err) + err = os.Chmod(aDir, os.ModePerm) + assert.NoError(t, err) + err = os.Remove(tmpFile) + assert.NoError(t, err) + } } func TestCommandsWithExtensionsFilter(t *testing.T) { @@ -875,6 +923,20 @@ func TestSSHCommandsRemoteFs(t *testing.T) { err = cmd.executeSystemCommand(command) assert.Error(t, err, "command must fail for a non local filesystem") + cmd = sshCommand{ + command: "sftpgo-copy", + connection: connection, + args: []string{}, + } + err = cmd.handeSFTPGoCopy() + assert.Error(t, err) + cmd = sshCommand{ + command: "sftpgo-remove", + connection: connection, + args: []string{}, + } + err = cmd.handeSFTPGoRemove() + assert.Error(t, err) } func TestSSHCommandQuotaScan(t *testing.T) { diff --git a/sftpd/sftpd.go b/sftpd/sftpd.go index 328d3182..7e7ef572 100644 --- a/sftpd/sftpd.go +++ b/sftpd/sftpd.go @@ -70,7 +70,7 @@ var ( uploadMode int setstatMode int supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", - "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} + "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync", "sftpgo-copy", "sftpgo-remove"} defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 2e073d9b..fdbcc8ad 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -3625,6 +3625,8 @@ func TestOverlappedMappedFolders(t *testing.T) { assert.Error(t, err) err = os.Remove(testFilePath) assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", path.Join(vdirPath1, subDir)), user, usePubKey) + assert.Error(t, err) } dataProvider = dataprovider.GetProvider() @@ -3657,10 +3659,87 @@ func TestOverlappedMappedFolders(t *testing.T) { err = os.RemoveAll(user.GetHomeDir()) assert.NoError(t, err) - err = os.RemoveAll(user.GetHomeDir()) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestResolveOverlappedMappedPaths(t *testing.T) { + u := getTestUser(false) + mappedPath1 := filepath.Join(os.TempDir(), "mapped1", "subdir") + vdirPath1 := "/vdir1/subdir" + mappedPath2 := filepath.Join(os.TempDir(), "mapped2") + vdirPath2 := "/vdir2/subdir" + mappedPath3 := filepath.Join(os.TempDir(), "mapped1") + vdirPath3 := "/vdir3" + mappedPath4 := filepath.Join(os.TempDir(), "mapped1", "subdir", "vdir4") + vdirPath4 := "/vdir4" + u.VirtualFolders = []vfs.VirtualFolder{ + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + }, + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + }, + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath3, + }, + VirtualPath: vdirPath3, + }, + { + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath4, + }, + VirtualPath: vdirPath4, + }, + } + err := os.MkdirAll(u.GetHomeDir(), os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath1, os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath3, os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath4, os.ModePerm) + assert.NoError(t, err) + + fs := vfs.NewOsFs("", u.GetHomeDir(), u.VirtualFolders) + p, err := fs.ResolvePath("/vdir1") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(u.GetHomeDir(), "vdir1"), p) + p, err = fs.ResolvePath("/vdir1/subdir") + assert.NoError(t, err) + assert.Equal(t, mappedPath1, p) + p, err = fs.ResolvePath("/vdir3/subdir/vdir4/file.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(mappedPath4, "file.txt"), p) + p, err = fs.ResolvePath("/vdir4/file.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(mappedPath4, "file.txt"), p) + assert.Equal(t, filepath.Join(mappedPath3, "subdir", "vdir4", "file.txt"), p) + assert.Equal(t, filepath.Join(mappedPath1, "vdir4", "file.txt"), p) + p, err = fs.ResolvePath("/vdir3/subdir/vdir4/../file.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(mappedPath3, "subdir", "file.txt"), p) + assert.Equal(t, filepath.Join(mappedPath1, "file.txt"), p) + + err = os.RemoveAll(u.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath4) assert.NoError(t, err) err = os.RemoveAll(mappedPath1) assert.NoError(t, err) + err = os.RemoveAll(mappedPath3) + assert.NoError(t, err) err = os.RemoveAll(mappedPath2) assert.NoError(t, err) } @@ -5148,6 +5227,367 @@ func TestSSHFileHash(t *testing.T) { assert.NoError(t, err) } +func TestSSHCopy(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1/subdir" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2/subdir" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 100, + QuotaSize: 0, + }) + u.Filters.FileExtensions = []dataprovider.ExtensionsFilter{ + { + Path: "/", + DeniedExtensions: []string{".denied"}, + }, + } + err := os.MkdirAll(mappedPath1, os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, os.ModePerm) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + testDir := "adir" + testDir1 := "adir1" + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileSize := int64(131072) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileName1 := "test_file1.dat" + testFileSize1 := int64(65537) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, testDir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, testDir1)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(testDir, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(testDir, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testDir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, testDir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testDir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, testDir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = client.Symlink(path.Join(testDir, testFileName), testFileName) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 4, user.UsedQuotaFiles) + assert.Equal(t, 2*testFileSize+2*testFileSize1, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + + _, err = client.Stat(testDir1) + assert.Error(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v", path.Join(vdirPath1, testDir1)), user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-copy", user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", testFileName, testFileName+".linkcopy"), user, usePubKey) + assert.Error(t, err) + out, err := runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1), "."), user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + fi, err := client.Stat(testDir1) + if assert.NoError(t, err) { + assert.True(t, fi.IsDir()) + } + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 6, user.UsedQuotaFiles) + assert.Equal(t, 3*testFileSize+3*testFileSize1, user.UsedQuotaSize) + } + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", "missing\\ dir", "."), user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1), "."), user, usePubKey) + assert.Error(t, err) + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath2, testDir1, testFileName), testFileName+".copy"), + user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + fi, err := client.Stat(testFileName + ".copy") + if assert.NoError(t, err) { + assert.True(t, fi.Mode().IsRegular()) + } + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 7, user.UsedQuotaFiles) + assert.Equal(t, 4*testFileSize+3*testFileSize1, user.UsedQuotaSize) + } + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1), path.Join(vdirPath2, testDir1+"copy")), + user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + fi, err := client.Stat(path.Join(vdirPath2, testDir1+"copy")) + if assert.NoError(t, err) { + assert.True(t, fi.IsDir()) + } + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 7, user.UsedQuotaFiles) + assert.Equal(t, 4*testFileSize+3*testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize*2+testFileSize1*2, f.UsedQuotaSize) + assert.Equal(t, 4, f.UsedQuotaFiles) + } + } + out, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1), path.Join(vdirPath1, testDir1+"copy")), + user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + _, err := client.Stat(path.Join(vdirPath2, testDir1+"copy")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 9, user.UsedQuotaFiles) + assert.Equal(t, 5*testFileSize+4*testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, 2*testFileSize+2*testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 4, f.UsedQuotaFiles) + } + } + + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath2, ".."), "newdir"), user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(testDir, testFileName), testFileName+".denied"), user, usePubKey) + assert.Error(t, err) + if runtime.GOOS != osWindows { + err = os.Chmod(filepath.Join(mappedPath1, testDir1), 0001) + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1), "newdir"), user, usePubKey) + assert.Error(t, err) + err = os.Chmod(filepath.Join(mappedPath1, testDir1), os.ModePerm) + assert.NoError(t, err) + err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir1), 0555) + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1, testFileName), + path.Join(testDir1, "anewdir")), user, usePubKey) + assert.Error(t, err) + err = os.Chmod(filepath.Join(user.GetHomeDir(), testDir1), os.ModePerm) + assert.NoError(t, err) + + err = os.RemoveAll(filepath.Join(user.GetHomeDir(), "vdir1")) + assert.NoError(t, err) + err = os.Chmod(user.GetHomeDir(), 0555) + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath2), "/vdir1"), user, usePubKey) + assert.Error(t, err) + err = os.Chmod(user.GetHomeDir(), os.ModePerm) + assert.NoError(t, err) + } + + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) + } + // test copy dir with no create dirs perm + user.Permissions["/"] = []string{dataprovider.PermUpload, dataprovider.PermDownload, dataprovider.PermListItems} + _, _, err = httpd.UpdateUser(user, http.StatusOK) + assert.NoError(t, err) + client, err = getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + _, err = runSSHCommand(fmt.Sprintf("sftpgo-copy %v %v", path.Join(vdirPath1, testDir1), testDir1+"copy1"), + user, usePubKey) + assert.Error(t, err) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestSSHRemove(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1/sub" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2/sub" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 100, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, os.ModePerm) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, os.ModePerm) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileSize := int64(131072) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileName1 := "test_file1.dat" + testFileSize1 := int64(65537) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + testDir := "testdir" + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, testDir)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, testDir)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, testFileName1, testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testDir, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, testDir, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testDir, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, testDir, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = client.Symlink(testFileName, testFileName+".link") + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", testFileName+".link"), user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-remove /vdir1", user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-remove /", user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-remove", user, usePubKey) + assert.Error(t, err) + out, err := runSSHCommand(fmt.Sprintf("sftpgo-remove %v", testFileName), user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + _, err := client.Stat(testFileName) + assert.Error(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+2*testFileSize1, user.UsedQuotaSize) + } + out, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", path.Join(vdirPath1, testDir)), user, usePubKey) + if assert.NoError(t, err) { + assert.Equal(t, "OK\n", string(out)) + _, err := client.Stat(path.Join(vdirPath1, testFileName)) + assert.Error(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + } + _, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", vdirPath1), user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-remove /", user, usePubKey) + assert.Error(t, err) + _, err = runSSHCommand("sftpgo-remove missing_file", user, usePubKey) + assert.Error(t, err) + if runtime.GOOS != osWindows { + err = os.Chmod(filepath.Join(mappedPath2, testDir), 0555) + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", path.Join(vdirPath2, testDir)), user, usePubKey) + assert.Error(t, err) + err = os.Chmod(filepath.Join(mappedPath2, testDir), 0001) + assert.NoError(t, err) + _, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", path.Join(vdirPath2, testDir)), user, usePubKey) + assert.Error(t, err) + err = os.Chmod(filepath.Join(mappedPath2, testDir), os.ModePerm) + assert.NoError(t, err) + } + } + + // test remove dir with no delete perm + user.Permissions["/"] = []string{dataprovider.PermUpload, dataprovider.PermDownload, dataprovider.PermListItems} + _, _, err = httpd.UpdateUser(user, http.StatusOK) + assert.NoError(t, err) + client, err = getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + _, err = runSSHCommand("sftpgo-remove adir", user, usePubKey) + assert.Error(t, err) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + func TestBasicGitCommands(t *testing.T) { if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows { t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test") diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index 19bd3001..c4b4e68f 100644 --- a/sftpd/ssh_cmd.go +++ b/sftpd/ssh_cmd.go @@ -11,11 +11,13 @@ import ( "io" "os" "os/exec" + "path" "strings" "sync" "time" "github.com/google/shlex" + fscopy "github.com/otiai10/copy" "golang.org/x/crypto/ssh" "github.com/drakkan/sftpgo/dataprovider" @@ -100,10 +102,149 @@ func (c *sshCommand) handle() error { // hard coded response to "/" c.connection.channel.Write([]byte("/\n")) //nolint:errcheck c.sendExitStatus(nil) + } else if c.command == "sftpgo-copy" { + return c.handeSFTPGoCopy() + } else if c.command == "sftpgo-remove" { + return c.handeSFTPGoRemove() } return nil } +func (c *sshCommand) handeSFTPGoCopy() error { + if !vfs.IsLocalOsFs(c.connection.fs) { + return c.sendErrorResponse(errUnsupportedConfig) + } + sshSourcePath, sshDestPath, err := c.getCopyPaths() + if err != nil { + return c.sendErrorResponse(err) + } + fsSourcePath, fsDestPath, err := c.resolveCopyPaths(sshSourcePath, sshDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + if err := c.checkCopyDestination(fsDestPath); err != nil { + return c.sendErrorResponse(err) + } + + c.connection.Log(logger.LevelDebug, logSenderSSH, "requested copy %#v -> %#v sftp paths %#v -> %#v", + fsSourcePath, fsDestPath, sshSourcePath, sshDestPath) + + fi, err := c.connection.fs.Lstat(fsSourcePath) + if err != nil { + return c.sendErrorResponse(err) + } + filesNum := 0 + filesSize := int64(0) + if fi.IsDir() { + if !c.connection.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(sshDestPath)) { + return c.sendErrorResponse(errPermissionDenied) + } + filesNum, filesSize, err = c.connection.fs.GetDirSize(fsSourcePath) + if err != nil { + return c.sendErrorResponse(err) + } + if c.connection.User.HasVirtualFoldersInside(sshSourcePath) { + err := errors.New("unsupported copy source: the source directory contains virtual folders") + return c.sendErrorResponse(err) + } + if c.connection.User.HasVirtualFoldersInside(sshDestPath) { + err := errors.New("unsupported copy source: the destination directory contains virtual folders") + return c.sendErrorResponse(err) + } + } else if fi.Mode().IsRegular() { + if !c.connection.User.IsFileAllowed(sshDestPath) { + err := errors.New("unsupported copy destination: this file is not allowed") + return c.sendErrorResponse(err) + } + filesNum = 1 + filesSize = fi.Size() + } else { + err := errors.New("unsupported copy source: only files and directories are supported") + return c.sendErrorResponse(err) + } + c.connection.Log(logger.LevelDebug, logSenderSSH, "start copy %#v -> %#v", fsSourcePath, fsDestPath) + err = fscopy.Copy(fsSourcePath, fsDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + c.updateQuota(sshDestPath, filesNum, filesSize) + c.connection.channel.Write([]byte("OK\n")) //nolint:errcheck + c.sendExitStatus(nil) + return nil +} + +func (c *sshCommand) handeSFTPGoRemove() error { + if !vfs.IsLocalOsFs(c.connection.fs) { + return c.sendErrorResponse(errUnsupportedConfig) + } + sshDestPath, err := c.getRemovePath() + if err != nil { + return c.sendErrorResponse(err) + } + if !c.connection.User.HasPerm(dataprovider.PermDelete, path.Dir(sshDestPath)) { + return c.sendErrorResponse(errPermissionDenied) + } + fsDestPath, err := c.connection.fs.ResolvePath(sshDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + fi, err := c.connection.fs.Lstat(fsDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + filesNum := 0 + filesSize := int64(0) + if fi.IsDir() { + filesNum, filesSize, err = c.connection.fs.GetDirSize(fsDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + if sshDestPath == "/" { + err := errors.New("removing root dir is not allowed") + return c.sendErrorResponse(err) + } + if c.connection.User.HasVirtualFoldersInside(sshDestPath) { + err := errors.New("unsupported remove source: this directory contains virtual folders") + return c.sendErrorResponse(err) + } + if c.connection.User.IsVirtualFolder(sshDestPath) { + err := errors.New("unsupported remove source: this directory is a virtual folder") + return c.sendErrorResponse(err) + } + if c.connection.User.IsMappedPath(fsDestPath) { + err := errors.New("removing a directory mapped as virtual folder is not allowed") + return c.sendErrorResponse(err) + } + } else if fi.Mode().IsRegular() { + filesNum = 1 + filesSize = fi.Size() + } else { + err := errors.New("unsupported remove source: only files and directories are supported") + return c.sendErrorResponse(err) + } + + err = os.RemoveAll(fsDestPath) + if err != nil { + return c.sendErrorResponse(err) + } + c.updateQuota(sshDestPath, -filesNum, -filesSize) + c.connection.channel.Write([]byte("OK\n")) //nolint:errcheck + c.sendExitStatus(nil) + return nil +} + +func (c *sshCommand) updateQuota(sshDestPath string, filesNum int, filesSize int64) { + vfolder, err := c.connection.User.GetVirtualFolderForPath(sshDestPath) + if err == nil { + dataprovider.UpdateVirtualFolderQuota(dataProvider, vfolder.BaseVirtualFolder, filesNum, filesSize, false) //nolint:errcheck + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.connection.User, filesNum, filesSize, false) //nolint:errcheck + } + } else { + dataprovider.UpdateUserQuota(dataProvider, c.connection.User, filesNum, filesSize, false) //nolint:errcheck + } +} + func (c *sshCommand) handleHashCommands() error { if !vfs.IsLocalOsFs(c.connection.fs) { return c.sendErrorResponse(errUnsupportedConfig) @@ -412,15 +553,27 @@ func (c *sshCommand) rescanHomeDir() error { return err } -// for the supported command, the path, if any, is the last argument +// for the supported commands, the destination path, if any, is the last argument func (c *sshCommand) getDestPath() string { if len(c.args) == 0 { return "" } - destPath := strings.Trim(c.args[len(c.args)-1], "'") - destPath = strings.Trim(destPath, "\"") - result := utils.CleanSFTPPath(destPath) - if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") { + return cleanCommandPath(c.args[len(c.args)-1]) +} + +// for the supported commands, the destination path, if any, is the second-last argument +func (c *sshCommand) getSourcePath() string { + if len(c.args) < 2 { + return "" + } + return cleanCommandPath(c.args[len(c.args)-2]) +} + +func cleanCommandPath(name string) string { + name = strings.Trim(name, "'") + name = strings.Trim(name, "\"") + result := utils.CleanSFTPPath(name) + if strings.HasSuffix(name, "/") && !strings.HasSuffix(result, "/") { result += "/" } return result @@ -437,6 +590,58 @@ func (c *sshCommand) getMappedError(err error) error { return err } +func (c *sshCommand) getCopyPaths() (string, string, error) { + sshSourcePath := strings.TrimSuffix(c.getSourcePath(), "/") + sshDestPath := c.getDestPath() + if strings.HasSuffix(sshDestPath, "/") { + sshDestPath = path.Join(sshDestPath, path.Base(sshSourcePath)) + } + if len(sshSourcePath) == 0 || len(sshDestPath) == 0 || len(c.args) != 2 { + err := errors.New("usage sftpgo-copy ") + return "", "", err + } + if !c.connection.User.HasPerm(dataprovider.PermListItems, path.Dir(sshSourcePath)) || + !c.connection.User.HasPerm(dataprovider.PermUpload, path.Dir(sshDestPath)) { + return "", "", errPermissionDenied + } + return sshSourcePath, sshDestPath, nil +} + +func (c *sshCommand) getRemovePath() (string, error) { + sshDestPath := c.getDestPath() + if len(sshDestPath) == 0 || len(c.args) != 1 { + err := errors.New("usage sftpgo-remove ") + return "", err + } + if len(sshDestPath) > 1 { + sshDestPath = strings.TrimSuffix(sshDestPath, "/") + } + return sshDestPath, nil +} + +func (c *sshCommand) resolveCopyPaths(sshSourcePath, sshDestPath string) (string, string, error) { + fsSourcePath, err := c.connection.fs.ResolvePath(sshSourcePath) + if err != nil { + return "", "", err + } + fsDestPath, err := c.connection.fs.ResolvePath(sshDestPath) + if err != nil { + return "", "", err + } + return fsSourcePath, fsDestPath, nil +} + +func (c *sshCommand) checkCopyDestination(fsDestPath string) error { + _, err := c.connection.fs.Lstat(fsDestPath) + if err == nil { + err := errors.New("invalid copy destination: cannot overwrite an existing file or directory") + return err + } else if !c.connection.fs.IsNotExist(err) { + return err + } + return nil +} + func (c *sshCommand) sendErrorResponse(err error) error { errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), c.getMappedError(err)) c.connection.channel.Write([]byte(errorString)) //nolint:errcheck @@ -446,12 +651,18 @@ func (c *sshCommand) sendErrorResponse(err error) error { func (c *sshCommand) sendExitStatus(err error) { status := uint32(0) + cmdPath := c.getDestPath() + targetPath := "" + if c.command == "sftpgo-copy" { + targetPath = cmdPath + cmdPath = c.getSourcePath() + } if err != nil { status = uint32(1) c.connection.Log(logger.LevelWarn, logSenderSSH, "command failed: %#v args: %v user: %v err: %v", c.command, c.args, c.connection.User.Username, err) } else { - logger.CommandLog(sshCommandLogSender, c.getDestPath(), "", c.connection.User.Username, "", c.connection.ID, + logger.CommandLog(sshCommandLogSender, cmdPath, targetPath, c.connection.User.Username, "", c.connection.ID, protocolSSH, -1, -1, "", "", c.connection.command) } exitStatus := sshSubsystemExitStatus{ @@ -462,14 +673,19 @@ func (c *sshCommand) sendExitStatus(err error) { // for scp we notify single uploads/downloads if c.command != scpCmdName { metrics.SSHCommandCompleted(err) - realPath := c.getDestPath() - if len(realPath) > 0 { - p, e := c.connection.fs.ResolvePath(realPath) + if len(cmdPath) > 0 { + p, e := c.connection.fs.ResolvePath(cmdPath) if e == nil { - realPath = p + cmdPath = p } } - go executeAction(newActionNotification(c.connection.User, operationSSHCmd, realPath, "", c.command, 0, err)) //nolint:errcheck + if len(targetPath) > 0 { + p, e := c.connection.fs.ResolvePath(targetPath) + if e == nil { + targetPath = p + } + } + go executeAction(newActionNotification(c.connection.User, operationSSHCmd, cmdPath, targetPath, c.command, 0, err)) //nolint:errcheck } }