From 35525e22e9afc6a7e389c8f567abf49b1a3ee732 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 28 Sep 2025 18:15:15 +0200 Subject: [PATCH] remove rsync support rsync was executed as an external command, which means we have no insight into or control over what it actually does. From a security perspective, this is far from ideal. To be clear, there's nothing inherently wrong with rsync itself. However, if we were to support it properly within SFTPGo, we would need to implement the low-level protocol internally rather than relying on launching an external process. This would ensure it works seamlessly with any storage backend, just as SFTP does, for example. We recommend using one of the many alternatives that rely on the SFTP protocol, such as rclone Signed-off-by: Nicola Murino --- internal/common/common_test.go | 13 - internal/dataprovider/user.go | 21 -- internal/sftpd/cmd_unix.go | 37 --- internal/sftpd/cmd_windows.go | 23 -- internal/sftpd/internal_test.go | 403 ++------------------------- internal/sftpd/internal_unix_test.go | 35 --- internal/sftpd/scp.go | 3 +- internal/sftpd/server.go | 1 + internal/sftpd/sftpd.go | 3 +- internal/sftpd/ssh_cmd.go | 377 +------------------------ internal/sftpd/subsystem.go | 87 ------ internal/sftpd/transfer.go | 61 ---- openapi/openapi.yaml | 2 +- 13 files changed, 31 insertions(+), 1035 deletions(-) delete mode 100644 internal/sftpd/cmd_unix.go delete mode 100644 internal/sftpd/cmd_windows.go delete mode 100644 internal/sftpd/internal_unix_test.go delete mode 100644 internal/sftpd/subsystem.go diff --git a/internal/common/common_test.go b/internal/common/common_test.go index 3024837c..cc85af0d 100644 --- a/internal/common/common_test.go +++ b/internal/common/common_test.go @@ -1412,19 +1412,6 @@ func TestUserPerms(t *testing.T) { u.Permissions["/"] = []string{dataprovider.PermDeleteDirs, dataprovider.PermRenameFiles, dataprovider.PermRenameDirs} assert.False(t, u.HasPermsDeleteAll("/")) assert.True(t, u.HasPermsRenameAll("/")) - - toCheck := []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, dataprovider.PermListItems, - dataprovider.PermOverwrite, dataprovider.PermDelete} - u.Permissions = make(map[string][]string) - u.Permissions["/"] = []string{dataprovider.PermListItems} - u.Permissions["/example-dir/bar"] = []string{dataprovider.PermListItems} - u.Permissions["/example-dir"] = toCheck - assert.True(t, u.HasPerms(toCheck, "/example-dir")) - assert.False(t, u.HasRecursivePerms(toCheck, "/example-dir")) - delete(u.Permissions, "/example-dir/bar") - assert.True(t, u.HasRecursivePerms(toCheck, "/example-dir")) - u.Permissions["/example-dirbar"] = []string{dataprovider.PermListItems} - assert.True(t, u.HasRecursivePerms(toCheck, "/example-dir")) } func TestGetTLSVersion(t *testing.T) { diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index 0b4c2cf5..c2d91bb7 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -876,27 +876,6 @@ func (u *User) HasAnyPerm(permissions []string, path string) bool { return false } -// HasRecursivePerms returns true if the user has all the specified permissions -// in the given folder and in every subfolder that has explicit permissions -// defined. -func (u *User) HasRecursivePerms(permissions []string, virtualPath string) bool { - if !u.HasPerms(permissions, virtualPath) { - return false - } - for dir, perms := range u.Permissions { - if len(dir) > len(virtualPath) { - if strings.HasPrefix(dir, virtualPath+"/") { - for _, permission := range permissions { - if !slices.Contains(perms, permission) { - return false - } - } - } - } - } - return true -} - // HasPerms returns true if the user has all the given permissions func (u *User) HasPerms(permissions []string, path string) bool { perms := u.GetPermissionsForPath(path) diff --git a/internal/sftpd/cmd_unix.go b/internal/sftpd/cmd_unix.go deleted file mode 100644 index 872bd9fc..00000000 --- a/internal/sftpd/cmd_unix.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) 2019 Nicola Murino -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build !windows - -package sftpd - -import ( - "os" - "os/exec" - "syscall" -) - -var ( - processUID = os.Geteuid() - processGID = os.Getegid() -) - -func wrapCmd(cmd *exec.Cmd, uid, gid int) *exec.Cmd { - isCurrentUser := processUID == uid && processGID == gid - if (uid > 0 || gid > 0) && !isCurrentUser { - cmd.SysProcAttr = &syscall.SysProcAttr{} - cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} - } - return cmd -} diff --git a/internal/sftpd/cmd_windows.go b/internal/sftpd/cmd_windows.go deleted file mode 100644 index 825045bf..00000000 --- a/internal/sftpd/cmd_windows.go +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (C) 2019 Nicola Murino -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package sftpd - -import ( - "os/exec" -) - -func wrapCmd(cmd *exec.Cmd, _, _ int) *exec.Cmd { - return cmd -} diff --git a/internal/sftpd/internal_test.go b/internal/sftpd/internal_test.go index 256344c2..7f749d8e 100644 --- a/internal/sftpd/internal_test.go +++ b/internal/sftpd/internal_test.go @@ -538,33 +538,6 @@ func TestSSHCommandErrors(t *testing.T) { _, err = cmd.connection.User.GetFilesystem("123") assert.NoError(t, err) - cmd.command = "git-receive-pack" - command, err := cmd.getSystemCommand() - assert.NoError(t, err) - - err = cmd.executeSystemCommand(command) - assert.Error(t, err, "invalid command must fail") - - command, err = cmd.getSystemCommand() - assert.NoError(t, err) - - _, err = command.cmd.StderrPipe() - assert.NoError(t, err) - - err = cmd.executeSystemCommand(command) - assert.Error(t, err, "command must fail, pipe was already assigned") - - err = cmd.executeSystemCommand(command) - assert.Error(t, err, "command must fail, pipe was already assigned") - - command, err = cmd.getSystemCommand() - assert.NoError(t, err) - - _, err = command.cmd.StdoutPipe() - 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, @@ -581,23 +554,6 @@ 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 = filepath.Clean(os.TempDir()) - - 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.PermAny} - - common.WaitForTransfers(1) - _, err = cmd.getSystemCommand() - if assert.Error(t, err) { - assert.Contains(t, err.Error(), common.ErrShuttingDown.Error()) - } - err = common.Initialize(common.Config, 0) assert.NoError(t, err) } @@ -638,14 +594,6 @@ func TestCommandsWithExtensionsFilter(t *testing.T) { } err := cmd.handleHashCommands() assert.EqualError(t, err, common.ErrPermissionDenied.Error()) - - cmd = sshCommand{ - command: "rsync", - connection: connection, - args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, - } - _, err = cmd.getSystemCommand() - assert.EqualError(t, err, errUnsupportedConfig.Error()) } func TestSSHCommandsRemoteFs(t *testing.T) { @@ -676,17 +624,7 @@ func TestSSHCommandsRemoteFs(t *testing.T) { args: []string{}, } - command, err := cmd.getSystemCommand() - assert.NoError(t, err) - - 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.handleSFTPGoCopy() + err := cmd.handleSFTPGoCopy() assert.Error(t, err) cmd = sshCommand{ command: "sftpgo-remove", @@ -735,246 +673,12 @@ func TestSSHCmdGetFsErrors(t *testing.T) { assert.NoError(t, err) } -func TestRsyncOptions(t *testing.T) { - permissions := make(map[string][]string) - permissions["/"] = []string{dataprovider.PermAny} - user := dataprovider.User{ - BaseUser: sdk.BaseUser{ - Permissions: permissions, - HomeDir: filepath.Clean(os.TempDir()), - }, - } - conn := &Connection{ - BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), - } - sshCmd := sshCommand{ - command: "rsync", - connection: conn, - args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, - } - cmd, err := sshCmd.getSystemCommand() - assert.NoError(t, err) - assert.Equal(t, []string{"rsync", "--server", "-vlogDtprze.iLsfxC", "--safe-links", ".", user.HomeDir + string(os.PathSeparator)}, cmd.cmd.Args, - "--safe-links must be added if the user has the create symlinks permission") - - permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, - dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename} - user.Permissions = permissions - - conn = &Connection{ - BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), - } - sshCmd = sshCommand{ - command: "rsync", - connection: conn, - } - _, err = sshCmd.getSystemCommand() - assert.Error(t, err) - sshCmd = sshCommand{ - command: "rsync", - connection: conn, - args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, - } - cmd, err = sshCmd.getSystemCommand() - assert.NoError(t, err) - assert.Equal(t, []string{"rsync", "--server", "-vlogDtprze.iLsfxC", "--munge-links", ".", user.HomeDir + string(os.PathSeparator)}, cmd.cmd.Args, - "--munge-links must be added if the user hasn't the create symlinks permission") - - sshCmd.connection.User.VirtualFolders = append(sshCmd.connection.User.VirtualFolders, vfs.VirtualFolder{ - BaseVirtualFolder: vfs.BaseVirtualFolder{ - MappedPath: os.TempDir(), - }, - VirtualPath: "/vdir", - }) - _, err = sshCmd.getSystemCommand() - assert.EqualError(t, err, errUnsupportedConfig.Error()) -} - -func TestSystemCommandSizeForPath(t *testing.T) { - permissions := make(map[string][]string) - permissions["/"] = []string{dataprovider.PermAny} - user := dataprovider.User{ - BaseUser: sdk.BaseUser{ - Permissions: permissions, - HomeDir: os.TempDir(), - }, - } - fs, err := user.GetFilesystem("123") - assert.NoError(t, err) - conn := &Connection{ - BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), - } - sshCmd := sshCommand{ - command: "rsync", - connection: conn, - args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, - } - _, _, err = sshCmd.getSizeForPath(fs, "missing path") - assert.NoError(t, err) - testDir := filepath.Join(os.TempDir(), "dir") - err = os.MkdirAll(testDir, os.ModePerm) - assert.NoError(t, err) - testFile := filepath.Join(testDir, "testfile") - err = os.WriteFile(testFile, []byte("test content"), os.ModePerm) - assert.NoError(t, err) - err = os.Symlink(testFile, testFile+".link") - assert.NoError(t, err) - numFiles, size, err := sshCmd.getSizeForPath(fs, testFile+".link") - assert.NoError(t, err) - assert.Equal(t, 0, numFiles) - assert.Equal(t, int64(0), size) - numFiles, size, err = sshCmd.getSizeForPath(fs, testFile) - assert.NoError(t, err) - assert.Equal(t, 1, numFiles) - assert.Equal(t, int64(12), size) - if runtime.GOOS != osWindows { - err = os.Chmod(testDir, 0001) - assert.NoError(t, err) - _, _, err = sshCmd.getSizeForPath(fs, testFile) - assert.Error(t, err) - err = os.Chmod(testDir, os.ModePerm) - assert.NoError(t, err) - } - err = os.RemoveAll(testDir) - assert.NoError(t, err) -} - -func TestSystemCommandErrors(t *testing.T) { - buf := make([]byte, 65535) - stdErrBuf := make([]byte, 65535) - readErr := fmt.Errorf("test read error") - writeErr := fmt.Errorf("test write error") - mockSSHChannel := MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - ReadError: nil, - WriteError: writeErr, - } - permissions := make(map[string][]string) - permissions["/"] = []string{dataprovider.PermAny} - homeDir := filepath.Join(os.TempDir(), "adir") - err := os.MkdirAll(homeDir, os.ModePerm) - assert.NoError(t, err) - err = os.WriteFile(filepath.Join(homeDir, "afile"), []byte("content"), os.ModePerm) - assert.NoError(t, err) - user := dataprovider.User{ - BaseUser: sdk.BaseUser{ - Permissions: permissions, - HomeDir: homeDir, - }, - } - fs, err := user.GetFilesystem("123") - assert.NoError(t, err) - connection := &Connection{ - BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), - channel: &mockSSHChannel, - } - var sshCmd sshCommand - if runtime.GOOS == osWindows { - sshCmd = sshCommand{ - command: "dir", - connection: connection, - args: []string{"/"}, - } - } else { - sshCmd = sshCommand{ - command: "ls", - connection: connection, - args: []string{"/"}, - } - } - systemCmd, err := sshCmd.getSystemCommand() - assert.NoError(t, err) - - systemCmd.cmd.Dir = os.TempDir() - // FIXME: the command completes but the fake client is unable to read the response - // no error is reported in this case. We can see that the expected code is executed - // reading the test coverage - sshCmd.executeSystemCommand(systemCmd) //nolint:errcheck - - mockSSHChannel = MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - ReadError: readErr, - WriteError: nil, - } - sshCmd.connection.channel = &mockSSHChannel - baseTransfer := common.NewBaseTransfer(nil, sshCmd.connection.BaseConnection, nil, "", "", "", - common.TransferUpload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{}) - transfer := newTransfer(baseTransfer, nil, nil, nil) - destBuff := make([]byte, 65535) - dst := bytes.NewBuffer(destBuff) - _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel) - assert.EqualError(t, err, readErr.Error()) - - mockSSHChannel = MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - ReadError: nil, - WriteError: nil, - } - sshCmd.connection.channel = &mockSSHChannel - transfer.MaxWriteSize = 1 - _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel) - assert.True(t, transfer.Connection.IsQuotaExceededError(err)) - - mockSSHChannel = MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - ReadError: nil, - WriteError: nil, - ShortWriteErr: true, - } - sshCmd.connection.channel = &mockSSHChannel - _, err = transfer.copyFromReaderToWriter(sshCmd.connection.channel, dst) - assert.EqualError(t, err, io.ErrShortWrite.Error()) - transfer.MaxWriteSize = -1 - _, err = transfer.copyFromReaderToWriter(sshCmd.connection.channel, dst) - assert.True(t, transfer.Connection.IsQuotaExceededError(err)) - err = transfer.Close() - assert.Error(t, err) - - baseTransfer = common.NewBaseTransfer(nil, sshCmd.connection.BaseConnection, nil, "", "", "", - common.TransferDownload, 0, 0, 0, 0, false, fs, dataprovider.TransferQuota{ - AllowedDLSize: 1, - }) - transfer = newTransfer(baseTransfer, nil, nil, nil) - mockSSHChannel = MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - ReadError: nil, - WriteError: nil, - } - sshCmd.connection.channel = &mockSSHChannel - _, err = transfer.copyFromReaderToWriter(dst, sshCmd.connection.channel) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), common.ErrReadQuotaExceeded.Error()) - } - err = transfer.Close() - assert.Error(t, err) - - err = os.RemoveAll(homeDir) - assert.NoError(t, err) - - assert.Equal(t, int32(0), common.Connections.GetTotalTransfers()) -} - func TestCommandGetFsError(t *testing.T) { user := dataprovider.User{ FsConfig: vfs.Filesystem{ Provider: sdk.CryptedFilesystemProvider, }, } - conn := &Connection{ - BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), - } - sshCmd := sshCommand{ - command: "rsync", - connection: conn, - args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, - } - _, err := sshCmd.getSystemCommand() - assert.Error(t, err) buf := make([]byte, 65535) stdErrBuf := make([]byte, 65535) @@ -983,7 +687,7 @@ func TestCommandGetFsError(t *testing.T) { StdErrBuffer: bytes.NewBuffer(stdErrBuf), ReadError: nil, } - conn = &Connection{ + conn := &Connection{ BaseConnection: common.NewBaseConnection("", common.ProtocolSCP, "", "", user), channel: &mockSSHChannel, } @@ -995,7 +699,7 @@ func TestCommandGetFsError(t *testing.T) { }, } - err = scpCommand.handleRecursiveUpload() + err := scpCommand.handleRecursiveUpload() assert.Error(t, err) err = scpCommand.handleDownload("") assert.Error(t, err) @@ -1910,40 +1614,6 @@ func TestCertCheckerInitErrors(t *testing.T) { assert.NoError(t, err) } -func TestSFTPSubSystem(t *testing.T) { - permissions := make(map[string][]string) - permissions["/"] = []string{dataprovider.PermAny} - user := &dataprovider.User{ - BaseUser: sdk.BaseUser{ - Permissions: permissions, - HomeDir: os.TempDir(), - }, - } - user.FsConfig.Provider = sdk.AzureBlobFilesystemProvider - err := ServeSubSystemConnection(user, "connID", nil, nil) - assert.Error(t, err) - user.FsConfig.Provider = sdk.LocalFilesystemProvider - - buf := make([]byte, 0, 4096) - stdErrBuf := make([]byte, 0, 4096) - mockSSHChannel := &MockChannel{ - Buffer: bytes.NewBuffer(buf), - StdErrBuffer: bytes.NewBuffer(stdErrBuf), - } - // this is 327680 and it will result in packet too long error - _, err = mockSSHChannel.Write([]byte{0x00, 0x05, 0x00, 0x00, 0x00, 0x00}) - assert.NoError(t, err) - err = ServeSubSystemConnection(user, "id", mockSSHChannel, mockSSHChannel) - assert.EqualError(t, err, "packet too long") - - subsystemChannel := newSubsystemChannel(mockSSHChannel, mockSSHChannel) - n, err := subsystemChannel.Write([]byte{0x00}) - assert.NoError(t, err) - assert.Equal(t, n, 1) - err = subsystemChannel.Close() - assert.NoError(t, err) -} - func TestRecoverer(t *testing.T) { c := Configuration{} c.AcceptInboundConnection(nil, nil) @@ -2074,9 +1744,27 @@ func TestMaxUserSessions(t *testing.T) { c := Configuration{} c.handleSftpConnection(nil, connection) + buf := make([]byte, 65535) + stdErrBuf := make([]byte, 65535) + mockSSHChannel := MockChannel{ + Buffer: bytes.NewBuffer(buf), + StdErrBuffer: bytes.NewBuffer(stdErrBuf), + } + + conn := &Connection{ + BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolSFTP, "", "", dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: "user_max_sessions", + HomeDir: filepath.Clean(os.TempDir()), + MaxSessions: 1, + }, + }), + channel: &mockSSHChannel, + } + sshCmd := sshCommand{ command: "cd", - connection: connection, + connection: conn, } err = sshCmd.handle() if assert.Error(t, err) { @@ -2085,17 +1773,14 @@ func TestMaxUserSessions(t *testing.T) { scpCmd := scpCommand{ sshCommand: sshCommand{ command: "scp", - connection: connection, + connection: conn, }, } err = scpCmd.handle() if assert.Error(t, err) { assert.Contains(t, err.Error(), "too many open sessions") } - err = ServeSubSystemConnection(&connection.User, connection.ID, nil, nil) - if assert.Error(t, err) { - assert.Contains(t, err.Error(), "too many open sessions") - } + common.Connections.Remove(connection.GetID()) assert.Len(t, common.Connections.GetStats(""), 0) assert.Equal(t, int32(0), common.Connections.GetTotalTransfers()) @@ -2157,43 +1842,3 @@ func TestAuthenticationErrors(t *testing.T) { assert.ErrorIs(t, err, sftpAuthError) assert.NotErrorIs(t, err, util.ErrNotFound) } - -func TestRsyncArguments(t *testing.T) { - assert.False(t, canAcceptRsyncArgs(nil)) - args := []string{"-e", "--server"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "."} - assert.True(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "--server", "-vlogDtpre.iLsfxCIvu", ".", "."} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "..", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "."} - assert.True(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} - assert.True(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} - assert.True(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "/", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "path1", "path2"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", "."} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--sender", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.", "--delete", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-vlogDtpre.", "--delete", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "--sender", "-e.iLsfxCIvu", ".", "/"} - assert.True(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "/"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "--safe-links"} - assert.False(t, canAcceptRsyncArgs(args)) - args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--unsupported-option", ".", "/"} - assert.False(t, canAcceptRsyncArgs(args)) -} diff --git a/internal/sftpd/internal_unix_test.go b/internal/sftpd/internal_unix_test.go deleted file mode 100644 index 2eafa208..00000000 --- a/internal/sftpd/internal_unix_test.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (C) 2019 Nicola Murino -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build !windows - -package sftpd - -import ( - "os/exec" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestWrapCmd(t *testing.T) { - cmd := exec.Command("ls") - cmd = wrapCmd(cmd, 3001, 3002) - assert.Equal(t, uint32(3001), cmd.SysProcAttr.Credential.Uid) - assert.Equal(t, uint32(3002), cmd.SysProcAttr.Credential.Gid) - - cmd = exec.Command("cd") - cmd = wrapCmd(cmd, processUID, processGID) - assert.Nil(t, cmd.SysProcAttr) -} diff --git a/internal/sftpd/scp.go b/internal/sftpd/scp.go index 653587e4..2d91afe3 100644 --- a/internal/sftpd/scp.go +++ b/internal/sftpd/scp.go @@ -51,8 +51,9 @@ func (c *scpCommand) handle() (err error) { } }() if err := common.Connections.Add(c.connection); err != nil { + defer c.connection.CloseFS() //nolint:errcheck logger.Info(logSender, "", "unable to add SCP connection: %v", err) - return err + return c.sendErrorResponse(err) } defer common.Connections.Remove(c.connection.GetID()) diff --git a/internal/sftpd/server.go b/internal/sftpd/server.go index b9fada24..54d50159 100644 --- a/internal/sftpd/server.go +++ b/internal/sftpd/server.go @@ -712,6 +712,7 @@ func (c *Configuration) handleSftpConnection(channel ssh.Channel, connection *Co } }() if err := common.Connections.Add(connection); err != nil { + defer connection.CloseFS() //nolint:errcheck errClose := connection.Disconnect() logger.Info(logSender, "", "unable to add connection: %v, close err: %v", err, errClose) return diff --git a/internal/sftpd/sftpd.go b/internal/sftpd/sftpd.go index 13e0a9bd..3f69cdde 100644 --- a/internal/sftpd/sftpd.go +++ b/internal/sftpd/sftpd.go @@ -31,10 +31,9 @@ const ( var ( supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", - "rsync", "sftpgo-copy", "sftpgo-remove"} + "sftpgo-copy", "sftpgo-remove"} defaultSSHCommands = []string{"md5sum", "sha1sum", "sha256sum", "cd", "pwd", "scp"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} - systemCommands = []string{"rsync"} serviceStatus ServiceStatus certKeyAlgoNames = map[string]string{ ssh.CertAlgoRSAv01: ssh.KeyAlgoRSA, diff --git a/internal/sftpd/ssh_cmd.go b/internal/sftpd/ssh_cmd.go index d4541ffb..c82f3b89 100644 --- a/internal/sftpd/ssh_cmd.go +++ b/internal/sftpd/ssh_cmd.go @@ -23,17 +23,12 @@ import ( "fmt" "hash" "io" - "os" - "os/exec" - "path" "runtime/debug" "slices" "strings" - "sync" "time" "github.com/google/shlex" - "github.com/sftpgo/sdk" "golang.org/x/crypto/ssh" "github.com/drakkan/sftpgo/v2/internal/common" @@ -49,10 +44,6 @@ const ( sshCommandLogSender = "SSHCommand" ) -var ( - errUnsupportedConfig = errors.New("command unsupported for this configuration") -) - type sshCommand struct { command string args []string @@ -60,32 +51,6 @@ type sshCommand struct { startTime time.Time } -type systemCommand struct { - cmd *exec.Cmd - fsPath string - quotaCheckPath string - fs vfs.Fs -} - -func (c *systemCommand) GetSTDs() (io.WriteCloser, io.ReadCloser, io.ReadCloser, error) { - stdin, err := c.cmd.StdinPipe() - if err != nil { - return nil, nil, nil, err - } - stdout, err := c.cmd.StdoutPipe() - if err != nil { - stdin.Close() - return nil, nil, nil, err - } - stderr, err := c.cmd.StderrPipe() - if err != nil { - stdin.Close() - stdout.Close() - return nil, nil, nil, err - } - return stdin, stdout, stderr, nil -} - func processSSHCommand(payload []byte, connection *Connection, enabledSSHCommands []string) bool { var msg sshSubsystemExecMsg if err := ssh.Unmarshal(payload, &msg); err == nil { @@ -134,20 +99,15 @@ func (c *sshCommand) handle() (err error) { } }() if err := common.Connections.Add(c.connection); err != nil { + defer c.connection.CloseFS() //nolint:errcheck logger.Info(logSender, "", "unable to add SSH command connection: %v", err) - return err + return c.sendErrorResponse(err) } defer common.Connections.Remove(c.connection.GetID()) c.connection.UpdateLastActivity() if slices.Contains(sshHashCommands, c.command) { return c.handleHashCommands() - } else if slices.Contains(systemCommands, c.command) { - command, err := c.getSystemCommand() - if err != nil { - return c.sendErrorResponse(err) - } - return c.executeSystemCommand(command) } else if c.command == "cd" { c.sendExitStatus(nil) } else if c.command == "pwd" { @@ -190,15 +150,6 @@ func (c *sshCommand) handleSFTPGoRemove() error { return nil } -func (c *sshCommand) updateQuota(sshDestPath string, filesNum int, filesSize int64) { - vfolder, err := c.connection.User.GetVirtualFolderForPath(sshDestPath) - if err == nil { - dataprovider.UpdateUserFolderQuota(&vfolder, &c.connection.User, filesNum, filesSize, false) - return - } - dataprovider.UpdateUserQuota(&c.connection.User, filesNum, filesSize, false) //nolint:errcheck -} - func (c *sshCommand) handleHashCommands() error { var h hash.Hash switch c.command { @@ -247,299 +198,6 @@ func (c *sshCommand) handleHashCommands() error { return nil } -func (c *sshCommand) executeSystemCommand(command systemCommand) error { //nolint:gocyclo - sshDestPath := c.getDestPath() - if !c.isLocalPath(sshDestPath) { - return c.sendErrorResponse(errUnsupportedConfig) - } - if err := common.Connections.IsNewTransferAllowed(c.connection.User.Username); err != nil { - err := fmt.Errorf("denying command due to transfer count limits") - return c.sendErrorResponse(err) - } - diskQuota, transferQuota := c.connection.HasSpace(true, false, command.quotaCheckPath) - if !diskQuota.HasSpace || !transferQuota.HasUploadSpace() || !transferQuota.HasDownloadSpace() { - return c.sendErrorResponse(common.ErrQuotaExceeded) - } - perms := []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, dataprovider.PermListItems, - dataprovider.PermOverwrite, dataprovider.PermDelete} - if !c.connection.User.HasRecursivePerms(perms, sshDestPath) { - return c.sendErrorResponse(c.connection.GetPermissionDeniedError()) - } - - initialFiles, initialSize, err := c.getSizeForPath(command.fs, command.fsPath) - if err != nil { - return c.sendErrorResponse(err) - } - - stdin, stdout, stderr, err := command.GetSTDs() - if err != nil { - return c.sendErrorResponse(err) - } - err = command.cmd.Start() - if err != nil { - return c.sendErrorResponse(err) - } - - closeCmdOnError := func() { - c.connection.Log(logger.LevelDebug, "kill cmd: %q and close ssh channel after read or write error", - c.connection.command) - killerr := command.cmd.Process.Kill() - closerr := c.connection.channel.Close() - c.connection.Log(logger.LevelDebug, "kill cmd error: %v close channel error: %v", killerr, closerr) - } - var once sync.Once - commandResponse := make(chan bool) - - remainingQuotaSize := diskQuota.GetRemainingSize() - - go func() { - defer stdin.Close() - baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath, - common.TransferUpload, 0, 0, remainingQuotaSize, 0, false, command.fs, transferQuota) - transfer := newTransfer(baseTransfer, nil, nil, nil) - - w, e := transfer.copyFromReaderToWriter(stdin, c.connection.channel) - c.connection.Log(logger.LevelDebug, "command: %q, copy from remote command to sdtin ended, written: %v, "+ - "initial remaining quota: %v, err: %v", c.connection.command, w, remainingQuotaSize, e) - if e != nil { - once.Do(closeCmdOnError) - } - }() - - go func() { - baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath, - common.TransferDownload, 0, 0, 0, 0, false, command.fs, transferQuota) - transfer := newTransfer(baseTransfer, nil, nil, nil) - - w, e := transfer.copyFromReaderToWriter(c.connection.channel, stdout) - c.connection.Log(logger.LevelDebug, "command: %q, copy from sdtout to remote command ended, written: %v err: %v", - c.connection.command, w, e) - if e != nil { - once.Do(closeCmdOnError) - } - commandResponse <- true - }() - - go func() { - baseTransfer := common.NewBaseTransfer(nil, c.connection.BaseConnection, nil, command.fsPath, command.fsPath, sshDestPath, - common.TransferDownload, 0, 0, 0, 0, false, command.fs, transferQuota) - transfer := newTransfer(baseTransfer, nil, nil, nil) - - w, e := transfer.copyFromReaderToWriter(c.connection.channel.(ssh.Channel).Stderr(), stderr) - c.connection.Log(logger.LevelDebug, "command: %q, copy from sdterr to remote command ended, written: %v err: %v", - c.connection.command, w, e) - // os.ErrClosed means that the command is finished so we don't need to do anything - if (e != nil && !errors.Is(e, os.ErrClosed)) || w > 0 { - once.Do(closeCmdOnError) - } - }() - - <-commandResponse - err = command.cmd.Wait() - c.sendExitStatus(err) - - numFiles, dirSize, errSize := c.getSizeForPath(command.fs, command.fsPath) - if errSize == nil { - c.updateQuota(sshDestPath, numFiles-initialFiles, dirSize-initialSize) - } - c.connection.Log(logger.LevelDebug, "command %q finished for path %q, initial files %v initial size %v "+ - "current files %v current size %v size err: %v", c.connection.command, command.fsPath, initialFiles, initialSize, - numFiles, dirSize, errSize) - return c.connection.GetFsError(command.fs, err) -} - -func (c *sshCommand) isSystemCommandAllowed() error { - sshDestPath := c.getDestPath() - if c.connection.User.IsVirtualFolder(sshDestPath) { - // overlapped virtual path are not allowed - return nil - } - if c.connection.User.HasVirtualFoldersInside(sshDestPath) { - c.connection.Log(logger.LevelDebug, "command %q is not allowed, path %q has virtual folders inside it, user %q", - c.command, sshDestPath, c.connection.User.Username) - return errUnsupportedConfig - } - for _, f := range c.connection.User.Filters.FilePatterns { - if f.Path == sshDestPath { - c.connection.Log(logger.LevelDebug, - "command %q is not allowed inside folders with file patterns filters %q user %q", - c.command, sshDestPath, c.connection.User.Username) - return errUnsupportedConfig - } - if len(sshDestPath) > len(f.Path) { - if strings.HasPrefix(sshDestPath, f.Path+"/") || f.Path == "/" { - c.connection.Log(logger.LevelDebug, - "command %q is not allowed it includes folders with file patterns filters %q user %q", - c.command, sshDestPath, c.connection.User.Username) - return errUnsupportedConfig - } - } - if len(sshDestPath) < len(f.Path) { - if strings.HasPrefix(sshDestPath+"/", f.Path) || sshDestPath == "/" { - c.connection.Log(logger.LevelDebug, - "command %q is not allowed inside folder with file patterns filters %q user %q", - c.command, sshDestPath, c.connection.User.Username) - return errUnsupportedConfig - } - } - } - return nil -} - -func (c *sshCommand) getSystemCommand() (systemCommand, error) { - command := systemCommand{ - cmd: nil, - fs: nil, - fsPath: "", - quotaCheckPath: "", - } - if err := common.CheckClosing(); err != nil { - return command, err - } - args := make([]string, len(c.args)) - copy(args, c.args) - var fsPath, quotaPath string - sshPath := c.getDestPath() - fs, err := c.connection.User.GetFilesystemForPath(sshPath, c.connection.ID) - if err != nil { - return command, err - } - if len(c.args) > 0 { - var err error - fsPath, err = fs.ResolvePath(sshPath) - if err != nil { - return command, c.connection.GetFsError(fs, err) - } - quotaPath = sshPath - fi, err := fs.Stat(fsPath) - if err == nil && fi.IsDir() { - // if the target is an existing dir the command will write inside this dir - // so we need to check the quota for this directory and not its parent dir - quotaPath = path.Join(sshPath, "fakecontent") - } - if strings.HasSuffix(sshPath, "/") && !strings.HasSuffix(fsPath, string(os.PathSeparator)) { - fsPath += string(os.PathSeparator) - c.connection.Log(logger.LevelDebug, "path separator added to fsPath %q", fsPath) - } - args = args[:len(args)-1] - args = append(args, fsPath) - } - if err := c.isSystemCommandAllowed(); err != nil { - return command, errUnsupportedConfig - } - if c.command == "rsync" { - if !canAcceptRsyncArgs(args) { - c.connection.Log(logger.LevelWarn, "invalid rsync command, args: %+v", args) - return command, errors.New("invalid or unsupported rsync command") - } - // 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 to create 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) - if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, c.getDestPath()) { - if !slices.Contains(args, "--safe-links") { - args = slices.Insert(args, len(args)-2, "--safe-links") - } - } else { - if !slices.Contains(args, "--munge-links") { - args = slices.Insert(args, len(args)-2, "--munge-links") - } - } - } - c.connection.Log(logger.LevelDebug, "new system command %q, with args: %+v fs path %q quota check path %q", - c.command, args, fsPath, quotaPath) - cmd := exec.Command(c.command, args...) - uid := c.connection.User.GetUID() - gid := c.connection.User.GetGID() - cmd = wrapCmd(cmd, uid, gid) - command.cmd = cmd - command.fsPath = fsPath - command.quotaCheckPath = quotaPath - command.fs = fs - return command, nil -} - -var ( - acceptedRsyncOptions = []string{ - "--existing", - "--ignore-existing", - "--remove-source-files", - "--delete", - "--delete-before", - "--delete-during", - "--delete-delay", - "--delete-after", - "--delete-excluded", - "--ignore-errors", - "--force", - "--partial", - "--delay-updates", - "--size-only", - "--blocking-io", - "--stats", - "--progress", - "--list-only", - "--dry-run", - } -) - -func canAcceptRsyncArgs(args []string) bool { - // We support the following formats: - // - // rsync --server -vlogDtpre.iLsfxCIvu --supported-options . ARG # push - // rsync --server --sender -vlogDtpre.iLsfxCIvu --supported-options . ARG # pull - // - // Then some options with a single dash and containing "e." followed by - // supported options, listed in acceptedRsyncOptions, with double dash then - // dot and a finally single argument specifying the path to operate on. - idx := 0 - if len(args) < 4 { - return false - } - // The first argument must be --server. - if args[idx] != "--server" { - return false - } - idx++ - // The second argument must be --sender or an argument starting with a - // single dash and containing "e." - if args[idx] == "--sender" { - idx++ - } - // Check that this argument starts with a dash and contains e. but does not - // end with e. - if !strings.HasPrefix(args[idx], "-") || strings.HasPrefix(args[idx], "--") || - !strings.Contains(args[idx], "e.") || strings.HasSuffix(args[idx], "e.") { - return false - } - idx++ - // We now expect optional supported options like --delete or a dot followed - // by the path to operate on. We don't support multiple paths in sender - // mode. - if len(args) < idx+2 { - return false - } - // A dot is required we'll check the expected position later. - if !slices.Contains(args, ".") { - return false - } - for _, arg := range args[idx:] { - if slices.Contains(acceptedRsyncOptions, arg) { - idx++ - } else { - if arg == "." { - idx++ - break - } - // Unsupported argument. - return false - } - } - return len(args) == idx+1 -} - // for the supported commands, the destination path, if any, is the last argument func (c *sshCommand) getDestPath() string { if len(c.args) == 0 { @@ -578,37 +236,6 @@ func (c *sshCommand) getRemovePath() (string, error) { return sshDestPath, nil } -func (c *sshCommand) isLocalPath(virtualPath string) bool { - folder, err := c.connection.User.GetVirtualFolderForPath(virtualPath) - if err != nil { - return c.connection.User.FsConfig.Provider == sdk.LocalFilesystemProvider - } - return folder.FsConfig.Provider == sdk.LocalFilesystemProvider -} - -func (c *sshCommand) getSizeForPath(fs vfs.Fs, name string) (int, int64, error) { - if dataprovider.GetQuotaTracking() > 0 { - fi, err := fs.Lstat(name) - if err != nil { - if fs.IsNotExist(err) { - return 0, 0, nil - } - c.connection.Log(logger.LevelDebug, "unable to stat %q error: %v", name, err) - return 0, 0, err - } - if fi.IsDir() { - files, size, err := fs.GetDirSize(name) - if err != nil { - c.connection.Log(logger.LevelDebug, "unable to get size for dir %q error: %v", name, err) - } - return files, size, err - } else if fi.Mode().IsRegular() { - return 1, fi.Size(), nil - } - } - return 0, 0, nil -} - func (c *sshCommand) sendErrorResponse(err error) error { errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), err) c.connection.channel.Write([]byte(errorString)) //nolint:errcheck diff --git a/internal/sftpd/subsystem.go b/internal/sftpd/subsystem.go deleted file mode 100644 index b17bd62e..00000000 --- a/internal/sftpd/subsystem.go +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (C) 2019 Nicola Murino -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package sftpd - -import ( - "io" - "net" - - "github.com/pkg/sftp" - - "github.com/drakkan/sftpgo/v2/internal/common" - "github.com/drakkan/sftpgo/v2/internal/dataprovider" - "github.com/drakkan/sftpgo/v2/internal/logger" -) - -type subsystemChannel struct { - reader io.Reader - writer io.Writer -} - -func (s *subsystemChannel) Read(p []byte) (int, error) { - return s.reader.Read(p) -} - -func (s *subsystemChannel) Write(p []byte) (int, error) { - return s.writer.Write(p) -} - -func (s *subsystemChannel) Close() error { - return nil -} - -func newSubsystemChannel(reader io.Reader, writer io.Writer) *subsystemChannel { - return &subsystemChannel{ - reader: reader, - writer: writer, - } -} - -// ServeSubSystemConnection handles a connection as SSH subsystem -func ServeSubSystemConnection(user *dataprovider.User, connectionID string, reader io.Reader, writer io.Writer) error { - err := user.CheckFsRoot(connectionID) - if err != nil { - errClose := user.CloseFs() - logger.Warn(logSender, connectionID, "unable to check fs root: %v close fs error: %v", err, errClose) - return err - } - - connection := &Connection{ - BaseConnection: common.NewBaseConnection(connectionID, common.ProtocolSFTP, "", "", *user), - ClientVersion: "", - RemoteAddr: &net.IPAddr{}, - LocalAddr: &net.IPAddr{}, - channel: newSubsystemChannel(reader, writer), - } - err = common.Connections.Add(connection) - if err != nil { - errClose := user.CloseFs() - logger.Warn(logSender, connectionID, "unable to add connection: %v close fs error: %v", err, errClose) - return err - } - defer common.Connections.Remove(connection.GetID()) - - dataprovider.UpdateLastLogin(user) - sftp.SetSFTPExtensions(sftpExtensions...) //nolint:errcheck - server := sftp.NewRequestServer(connection.channel, sftp.Handlers{ - FileGet: connection, - FilePut: connection, - FileCmd: connection, - FileList: connection, - }) - - defer server.Close() - return server.Serve() -} diff --git a/internal/sftpd/transfer.go b/internal/sftpd/transfer.go index 23ce72d2..465ad8c3 100644 --- a/internal/sftpd/transfer.go +++ b/internal/sftpd/transfer.go @@ -19,7 +19,6 @@ import ( "io" "github.com/drakkan/sftpgo/v2/internal/common" - "github.com/drakkan/sftpgo/v2/internal/metric" "github.com/drakkan/sftpgo/v2/internal/vfs" ) @@ -192,63 +191,3 @@ func (t *transfer) setFinished() error { t.isFinished = true return nil } - -// used for ssh commands. -// It reads from src until EOF so it does not treat an EOF from Read as an error to be reported. -// EOF from Write is reported as error -func (t *transfer) copyFromReaderToWriter(dst io.Writer, src io.Reader) (int64, error) { - defer t.Connection.RemoveTransfer(t) - - var written int64 - var err error - - if t.MaxWriteSize < 0 { - return 0, common.ErrQuotaExceeded - } - isDownload := t.GetType() == common.TransferDownload - buf := make([]byte, 32768) - for { - t.Connection.UpdateLastActivity() - nr, er := src.Read(buf) - if nr > 0 { - nw, ew := dst.Write(buf[0:nr]) - if nw > 0 { - written += int64(nw) - if isDownload { - t.BytesSent.Store(written) - if errCheck := t.CheckRead(); errCheck != nil { - err = errCheck - break - } - } else { - t.BytesReceived.Store(written) - if errCheck := t.CheckWrite(); errCheck != nil { - err = errCheck - break - } - } - } - if ew != nil { - err = ew - break - } - if nr != nw { - err = io.ErrShortWrite - break - } - } - if er != nil { - if er != io.EOF { - err = er - } - break - } - t.HandleThrottle() - } - t.ErrTransfer = err - if written > 0 || err != nil { - metric.TransferCompleted(t.BytesSent.Load(), t.BytesReceived.Load(), t.GetType(), - t.ErrTransfer, vfs.IsSFTPFs(t.Fs)) - } - return written, err -} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5bd8e924..58c898b4 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -5385,7 +5385,7 @@ components: max_upload_file_size: type: integer format: int64 - description: 'maximum allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited. This restriction does not apply for SSH system commands such as `git` and `rsync`' + description: 'maximum allowed size, as bytes, for a single file upload. The upload will be aborted if/when the size of the file being sent exceeds this limit. 0 means unlimited' tls_username: type: string description: 'defines the TLS certificate field to use as username. For FTP clients it must match the name provided using the "USER" command. For WebDAV, if no username is provided, the CN will be used as username. For WebDAV clients it must match the implicit or provided username. Ignored if mutual TLS is disabled. Currently the only supported value is `CommonName`'