ssh commands: add sftpgo-copy and sftpgo-remove

Fixes #122
This commit is contained in:
Nicola Murino 2020-06-13 22:48:51 +02:00
parent 8e22dd1b13
commit 3d48fa7382
11 changed files with 766 additions and 42 deletions

View file

@ -173,26 +173,9 @@ More in-depth analysis of performance can be found [here](./docs/performance.md)
## Acknowledgements ## Acknowledgements
- [pkg/sftp](https://github.com/pkg/sftp) SFTPGo makes use of the third party libraries listed inside [go.mod](./go.mod).
- [go-chi](https://github.com/go-chi/chi) Some code was initially taken from [Pterodactyl SFTP Server](https://github.com/pterodactyl/sftp-server).
- [zerolog](https://github.com/rs/zerolog) We are very grateful to all the people who contributed with ideas and/or pull requests.
- [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)
## License ## License

View file

@ -16,7 +16,7 @@ FROM debian:latest
RUN apt-get update && apt-get install -y ca-certificates 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. # 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 BASE_DIR=/app
ARG DATA_REL_DIR=data ARG DATA_REL_DIR=data

View file

@ -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` - `action`, string, possible values are: `download`, `upload`, `pre-delete`,`delete`, `rename`, `ssh_cmd`
- `username` - `username`
- `path` is the full filesystem path, can be empty for some ssh commands - `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 - `ssh_cmd`, non-empty for `ssh_cmd` action
The external program can also read the following environment variables: The external program can also read the following environment variables:

View file

@ -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. - `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. - `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. - `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: - `enabled_ssh_commands`, list of enabled SSH commands. `*` enables all supported commands. More information can be found [here](./ssh-commands.md).
- `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.
- `keyboard_interactive_auth_program`, string. Deprecated, please use `keyboard_interactive_auth_hook`. - `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. - `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: - `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:

21
docs/ssh-commands.md Normal file
View file

@ -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 <src> <dst>`. 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 <dst>`.
The following SSH commands are enabled by default:
- `md5sum`
- `sha1sum`
- `cd`
- `pwd`
- `scp`

1
go.mod
View file

@ -19,6 +19,7 @@ require (
github.com/miekg/dns v1.1.29 // indirect github.com/miekg/dns v1.1.29 // indirect
github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/mitchellh/mapstructure v1.2.2 // indirect
github.com/nathanaelle/password/v2 v2.0.1 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/pelletier/go-toml v1.7.0 // indirect
github.com/pires/go-proxyproto v0.1.3 github.com/pires/go-proxyproto v0.1.3
github.com/pkg/sftp v1.11.1-0.20200310224833-18dc4db7a456 github.com/pkg/sftp v1.11.1-0.20200310224833-18dc4db7a456

6
go.sum
View file

@ -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 h1:ItoCTdsuIWzilYmllQPa3DR3YoCXcpfxScWLqr8Ii2s=
github.com/nathanaelle/password/v2 v2.0.1/go.mod h1:eaoT+ICQEPNtikBRIAatN8ThWwMhVG+r1jTw60BvPJk= 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/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.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 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=

View file

@ -712,7 +712,7 @@ func TestSSHCommandErrors(t *testing.T) {
err = cmd.handle() err = cmd.handle()
assert.Error(t, err, "ssh command must fail, we are requesting an invalid path") 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.QuotaFiles = 1
cmd.connection.User.UsedQuotaFiles = 2 cmd.connection.User.UsedQuotaFiles = 2
fs, err = cmd.connection.User.GetFilesystem("123") fs, err = cmd.connection.User.GetFilesystem("123")
@ -755,6 +755,54 @@ func TestSSHCommandErrors(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = cmd.executeSystemCommand(command) err = cmd.executeSystemCommand(command)
assert.Error(t, err, "command must fail, pipe was already assigned") 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) { func TestCommandsWithExtensionsFilter(t *testing.T) {
@ -875,6 +923,20 @@ func TestSSHCommandsRemoteFs(t *testing.T) {
err = cmd.executeSystemCommand(command) err = cmd.executeSystemCommand(command)
assert.Error(t, err, "command must fail for a non local filesystem") 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) { func TestSSHCommandQuotaScan(t *testing.T) {

View file

@ -70,7 +70,7 @@ var (
uploadMode int uploadMode int
setstatMode int setstatMode int
supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", 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"} defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"}
sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}

View file

@ -3625,6 +3625,8 @@ func TestOverlappedMappedFolders(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
err = os.Remove(testFilePath) err = os.Remove(testFilePath)
assert.NoError(t, err) assert.NoError(t, err)
_, err = runSSHCommand(fmt.Sprintf("sftpgo-remove %v", path.Join(vdirPath1, subDir)), user, usePubKey)
assert.Error(t, err)
} }
dataProvider = dataprovider.GetProvider() dataProvider = dataprovider.GetProvider()
@ -3657,10 +3659,87 @@ func TestOverlappedMappedFolders(t *testing.T) {
err = os.RemoveAll(user.GetHomeDir()) err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err) 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) assert.NoError(t, err)
err = os.RemoveAll(mappedPath1) err = os.RemoveAll(mappedPath1)
assert.NoError(t, err) assert.NoError(t, err)
err = os.RemoveAll(mappedPath3)
assert.NoError(t, err)
err = os.RemoveAll(mappedPath2) err = os.RemoveAll(mappedPath2)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -5148,6 +5227,367 @@ func TestSSHFileHash(t *testing.T) {
assert.NoError(t, err) 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) { func TestBasicGitCommands(t *testing.T) {
if len(gitPath) == 0 || len(sshPath) == 0 || runtime.GOOS == osWindows { 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") t.Skip("git and/or ssh command not found or OS is windows, unable to execute this test")

View file

@ -11,11 +11,13 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/google/shlex" "github.com/google/shlex"
fscopy "github.com/otiai10/copy"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/dataprovider"
@ -100,10 +102,149 @@ func (c *sshCommand) handle() error {
// hard coded response to "/" // hard coded response to "/"
c.connection.channel.Write([]byte("/\n")) //nolint:errcheck c.connection.channel.Write([]byte("/\n")) //nolint:errcheck
c.sendExitStatus(nil) c.sendExitStatus(nil)
} else if c.command == "sftpgo-copy" {
return c.handeSFTPGoCopy()
} else if c.command == "sftpgo-remove" {
return c.handeSFTPGoRemove()
} }
return nil 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 { func (c *sshCommand) handleHashCommands() error {
if !vfs.IsLocalOsFs(c.connection.fs) { if !vfs.IsLocalOsFs(c.connection.fs) {
return c.sendErrorResponse(errUnsupportedConfig) return c.sendErrorResponse(errUnsupportedConfig)
@ -412,15 +553,27 @@ func (c *sshCommand) rescanHomeDir() error {
return err 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 { func (c *sshCommand) getDestPath() string {
if len(c.args) == 0 { if len(c.args) == 0 {
return "" return ""
} }
destPath := strings.Trim(c.args[len(c.args)-1], "'") return cleanCommandPath(c.args[len(c.args)-1])
destPath = strings.Trim(destPath, "\"") }
result := utils.CleanSFTPPath(destPath)
if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") { // 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 += "/" result += "/"
} }
return result return result
@ -437,6 +590,58 @@ func (c *sshCommand) getMappedError(err error) error {
return err 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 <source dir path> <destination dir path>")
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 <destination path>")
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 { func (c *sshCommand) sendErrorResponse(err error) error {
errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), c.getMappedError(err)) errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), c.getMappedError(err))
c.connection.channel.Write([]byte(errorString)) //nolint:errcheck 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) { func (c *sshCommand) sendExitStatus(err error) {
status := uint32(0) status := uint32(0)
cmdPath := c.getDestPath()
targetPath := ""
if c.command == "sftpgo-copy" {
targetPath = cmdPath
cmdPath = c.getSourcePath()
}
if err != nil { if err != nil {
status = uint32(1) status = uint32(1)
c.connection.Log(logger.LevelWarn, logSenderSSH, "command failed: %#v args: %v user: %v err: %v", c.connection.Log(logger.LevelWarn, logSenderSSH, "command failed: %#v args: %v user: %v err: %v",
c.command, c.args, c.connection.User.Username, err) c.command, c.args, c.connection.User.Username, err)
} else { } 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) protocolSSH, -1, -1, "", "", c.connection.command)
} }
exitStatus := sshSubsystemExitStatus{ exitStatus := sshSubsystemExitStatus{
@ -462,14 +673,19 @@ func (c *sshCommand) sendExitStatus(err error) {
// for scp we notify single uploads/downloads // for scp we notify single uploads/downloads
if c.command != scpCmdName { if c.command != scpCmdName {
metrics.SSHCommandCompleted(err) metrics.SSHCommandCompleted(err)
realPath := c.getDestPath() if len(cmdPath) > 0 {
if len(realPath) > 0 { p, e := c.connection.fs.ResolvePath(cmdPath)
p, e := c.connection.fs.ResolvePath(realPath)
if e == nil { 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
} }
} }