diff --git a/common/common.go b/common/common.go index cfdbc558..6cd96e75 100644 --- a/common/common.go +++ b/common/common.go @@ -223,6 +223,8 @@ type Configuration struct { Actions ProtocolActions `json:"actions" mapstructure:"actions"` // SetstatMode 0 means "normal mode": requests for changing permissions and owner/group are executed. // 1 means "ignore mode": requests for changing permissions and owner/group are silently ignored. + // 2 means "ignore mode for cloud fs": requests for changing permissions and owner/group/time are + // silently ignored for cloud based filesystem such as S3, GCS, Azure Blob SetstatMode int `json:"setstat_mode" mapstructure:"setstat_mode"` // Support for HAProxy PROXY protocol. // If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable diff --git a/common/connection.go b/common/connection.go index 12c2da39..f46f6caf 100644 --- a/common/connection.go +++ b/common/connection.go @@ -448,17 +448,28 @@ func (c *BaseConnection) DoStat(fsPath string, mode int) (os.FileInfo, error) { return c.Fs.Stat(c.getRealFsPath(fsPath)) } -// SetStat set StatAttributes for the specified fsPath -func (c *BaseConnection) SetStat(fsPath, virtualPath string, attributes *StatAttributes) error { +func (c *BaseConnection) ignoreSetStat() bool { if Config.SetstatMode == 1 { - return nil + return true } + if Config.SetstatMode == 2 && !vfs.IsLocalOsFs(c.Fs) { + return true + } + return false +} + +// SetStat set StatAttributes for the specified fsPath +// nolint:gocyclo +func (c *BaseConnection) SetStat(fsPath, virtualPath string, attributes *StatAttributes) error { pathForPerms := c.getPathForSetStatPerms(fsPath, virtualPath) if attributes.Flags&StatAttrPerms != 0 { if !c.User.HasPerm(dataprovider.PermChmod, pathForPerms) { return c.GetPermissionDeniedError() } + if c.ignoreSetStat() { + return nil + } if err := c.Fs.Chmod(c.getRealFsPath(fsPath), attributes.Mode); err != nil { c.Log(logger.LevelWarn, "failed to chmod path %#v, mode: %v, err: %+v", fsPath, attributes.Mode.String(), err) return c.GetFsError(err) @@ -471,6 +482,9 @@ func (c *BaseConnection) SetStat(fsPath, virtualPath string, attributes *StatAtt if !c.User.HasPerm(dataprovider.PermChown, pathForPerms) { return c.GetPermissionDeniedError() } + if c.ignoreSetStat() { + return nil + } if err := c.Fs.Chown(c.getRealFsPath(fsPath), attributes.UID, attributes.GID); err != nil { c.Log(logger.LevelWarn, "failed to chown path %#v, uid: %v, gid: %v, err: %+v", fsPath, attributes.UID, attributes.GID, err) @@ -484,7 +498,9 @@ func (c *BaseConnection) SetStat(fsPath, virtualPath string, attributes *StatAtt if !c.User.HasPerm(dataprovider.PermChtimes, pathForPerms) { return c.GetPermissionDeniedError() } - + if c.ignoreSetStat() { + return nil + } if err := c.Fs.Chtimes(c.getRealFsPath(fsPath), attributes.Atime, attributes.Mtime); err != nil { c.Log(logger.LevelWarn, "failed to chtimes for path %#v, access time: %v, modification time: %v, err: %+v", fsPath, attributes.Atime, attributes.Mtime, err) @@ -950,6 +966,8 @@ func (c *BaseConnection) GetFsError(err error) error { return c.GetNotExistError() } else if c.Fs.IsPermission(err) { return c.GetPermissionDeniedError() + } else if c.Fs.IsNotSupported(err) { + return c.GetOpUnsupportedError() } else if err != nil { return c.GetGenericError(err) } diff --git a/common/connection_test.go b/common/connection_test.go index 3a9712ca..50857e52 100644 --- a/common/connection_test.go +++ b/common/connection_test.go @@ -496,6 +496,29 @@ func TestSetStat(t *testing.T) { err = c.SetStat(user.GetHomeDir(), "/", &StatAttributes{}) assert.NoError(t, err) + err = c.SetStat(dir2, "/dir1/file", &StatAttributes{ + Mode: os.ModePerm, + Flags: StatAttrPerms, + }) + assert.NoError(t, err) + err = c.SetStat(dir1, "/dir2/file", &StatAttributes{ + UID: os.Getuid(), + GID: os.Getgid(), + Flags: StatAttrUIDGID, + }) + assert.NoError(t, err) + err = c.SetStat(dir1, "/dir3/file", &StatAttributes{ + Atime: time.Now(), + Mtime: time.Now(), + Flags: StatAttrTimes, + }) + assert.NoError(t, err) + + Config.SetstatMode = 2 + assert.False(t, c.ignoreSetStat()) + c1 := NewBaseConnection("", ProtocolSFTP, user, newMockOsFs(false, fs.ConnectionID(), user.GetHomeDir())) + assert.True(t, c1.ignoreSetStat()) + Config.SetstatMode = oldSetStatMode // chmod err = c.SetStat(dir1, "/dir1/file", &StatAttributes{ @@ -1146,6 +1169,12 @@ func TestErrorsMapping(t *testing.T) { } else { assert.EqualError(t, err, ErrPermissionDenied.Error()) } + err = conn.GetFsError(vfs.ErrVfsUnsupported) + if protocol == ProtocolSFTP { + assert.EqualError(t, err, sftp.ErrSSHFxOpUnsupported.Error()) + } else { + assert.EqualError(t, err, ErrOpUnsupported.Error()) + } err = conn.GetFsError(nil) assert.NoError(t, err) err = conn.GetOpUnsupportedError() diff --git a/docker/scripts/entrypoint-alpine.sh b/docker/scripts/entrypoint-alpine.sh index 5862b45f..ff22fc0f 100755 --- a/docker/scripts/entrypoint-alpine.sh +++ b/docker/scripts/entrypoint-alpine.sh @@ -10,7 +10,7 @@ if [ "$1" = 'sftpgo' ]; then DIR_UID=$(stat -c %u ${DIR}) DIR_GID=$(stat -c %g ${DIR}) if [ ${DIR_UID} != ${SFTPGO_PUID} ] || [ ${DIR_GID} != ${SFTPGO_PGID} ]; then - echo `date +%Y-%m-%dT%H:%M:%S` - "entrypoint, change owner for ${DIR} uid: ${SFTPGO_PUID} gid: ${SFTPGO_PGID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.000`'","sender":"entrypoint","message":"change owner for \"'${DIR}'\" UID: '${SFTPGO_PUID}' GID: '${SFTPGO_PGID}'"}' if [ ${DIR} = "/etc/sftpgo" ]; then chown -R ${SFTPGO_PUID}:${SFTPGO_PGID} ${DIR} else @@ -18,7 +18,7 @@ if [ "$1" = 'sftpgo' ]; then fi fi done - echo `date +%Y-%m-%dT%H:%M:%S` - "entrypoint, run as uid: ${SFTPGO_PUID} gid: ${SFTPGO_PGID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.000`'","sender":"entrypoint","message":"run as UID: '${SFTPGO_PUID}' GID: '${SFTPGO_PGID}'"}' exec su-exec ${SFTPGO_PUID}:${SFTPGO_PGID} "$@" fi diff --git a/docker/scripts/entrypoint.sh b/docker/scripts/entrypoint.sh index 8d5d07c7..04d29c7a 100755 --- a/docker/scripts/entrypoint.sh +++ b/docker/scripts/entrypoint.sh @@ -10,19 +10,19 @@ if [ "$1" = 'sftpgo' ]; then getent group ${SFTPGO_PGID} > /dev/null HAS_PGID=$? if [ ${HAS_PUID} -ne 0 ] || [ ${HAS_PGID} -ne 0 ]; then - echo `date +%Y-%m-%dT%H:%M:%S.%3N` - "entrypoint, prepare to run as uid: ${SFTPGO_PUID} gid: ${SFTPGO_PGID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.%3N`'","sender":"entrypoint","message":"prepare to run as UID: '${SFTPGO_PUID}' GID: '${SFTPGO_PGID}'"}' if [ ${HAS_PGID} -ne 0 ]; then - echo `date +%Y-%m-%dT%H:%M:%S.%3N` - "entrypoint, set GID to: ${SFTPGO_PGID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.%3N`'","sender":"entrypoint","message":"set GID to: '${SFTPGO_PGID}'"}' groupmod -g ${SFTPGO_PGID} sftpgo fi if [ ${HAS_PUID} -ne 0 ]; then - echo `date +%Y-%m-%dT%H:%M:%S.%3N` - "entrypoint, set UID to: ${SFTPGO_PUID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.%3N`'","sender":"entrypoint","message":"set UID to: '${SFTPGO_PUID}'"}' usermod -u ${SFTPGO_PUID} sftpgo fi chown -R ${SFTPGO_PUID}:${SFTPGO_PGID} /etc/sftpgo chown ${SFTPGO_PUID}:${SFTPGO_PGID} /var/lib/sftpgo /srv/sftpgo fi - echo `date +%Y-%m-%dT%H:%M:%S.%3N` - "entrypoint, run as uid: ${SFTPGO_PUID} gid: ${SFTPGO_PGID}" + echo '{"level":"info","time":"'`date +%Y-%m-%dT%H:%M:%S.%3N`'","sender":"entrypoint","message":"run as UID: '${SFTPGO_PUID}' GID: '${SFTPGO_PGID}'"}' exec gosu ${SFTPGO_PUID}:${SFTPGO_PGID} "$@" fi diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 2c9af933..1404a934 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -54,7 +54,7 @@ The configuration file contains the following sections: - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See [Custom Actions](./custom-actions.md) for more details - `execute_on`, list of strings. Valid values are `download`, `upload`, `pre-delete`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions. - `hook`, string. Absolute path to the command to execute or HTTP URL to notify. - - `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. 2 means "ignore mode for cloud based filesystems": requests for changing permissions, owner/group and access/modification times are silently ignored for cloud filesystems and executed for local filesystem. - `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: - 0, disabled - 1, enabled. Proxy header will be used and requests without proxy header will be accepted diff --git a/docs/s3.md b/docs/s3.md index 992ce37c..f061d69d 100644 --- a/docs/s3.md +++ b/docs/s3.md @@ -20,9 +20,8 @@ The configured bucket must exist. Some SFTP commands don't work over S3: -- `symlink` and `chtimes` will fail -- `chown` and `chmod` are silently ignored -- `truncate` is not supported +- `chtimes`, `chown` and `chmod` will fail. If you want to silently ignore these method set `setstat_mode` to `1` or `2` in your configuration file +- `truncate`, `symlink`, `readlink` are not supported - opening a file for both reading and writing at the same time is not supported - upload resume is not supported - upload mode `atomic` is ignored since S3 uploads are already atomic diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index a167d8fd..ee77fd12 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -397,17 +397,6 @@ func TestSFTPCmdTargetPath(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestSetstatModeIgnore(t *testing.T) { - originalMode := common.Config.SetstatMode - common.Config.SetstatMode = 1 - connection := Connection{} - request := sftp.NewRequest("Setstat", "invalid") - request.Flags = 0 - err := connection.handleSFTPSetstat("invalid", request) - assert.NoError(t, err) - common.Config.SetstatMode = originalMode -} - func TestSFTPGetUsedQuota(t *testing.T) { u := dataprovider.User{} u.HomeDir = "home_rel_path" diff --git a/vfs/azblobfs.go b/vfs/azblobfs.go index ea5411d5..39e18f86 100644 --- a/vfs/azblobfs.go +++ b/vfs/azblobfs.go @@ -361,37 +361,34 @@ func (fs *AzureBlobFs) Mkdir(name string) error { // Symlink creates source as a symbolic link to target. func (*AzureBlobFs) Symlink(source, target string) error { - return errors.New("403 symlinks are not supported") + return ErrVfsUnsupported } // Readlink returns the destination of the named symbolic link func (*AzureBlobFs) Readlink(name string) (string, error) { - return "", errors.New("403 readlink is not supported") + return "", ErrVfsUnsupported } // Chown changes the numeric uid and gid of the named file. -// Silently ignored. func (*AzureBlobFs) Chown(name string, uid int, gid int) error { - return nil + return ErrVfsUnsupported } // Chmod changes the mode of the named file to mode. -// Silently ignored. func (*AzureBlobFs) Chmod(name string, mode os.FileMode) error { - return nil + return ErrVfsUnsupported } // Chtimes changes the access and modification times of the named file. -// Silently ignored. func (*AzureBlobFs) Chtimes(name string, atime, mtime time.Time) error { - return errors.New("403 chtimes is not supported") + return ErrVfsUnsupported } // Truncate changes the size of the named file. // Truncate by path is not supported, while truncating an opened // file is handled inside base transfer func (*AzureBlobFs) Truncate(name string, size int64) error { - return errors.New("403 truncate is not supported") + return ErrVfsUnsupported } // ReadDir reads the directory named by dirname and returns @@ -519,6 +516,14 @@ func (*AzureBlobFs) IsPermission(err error) bool { return strings.Contains(err.Error(), "403") } +// IsNotSupported returns true if the error indicate an unsupported operation +func (*AzureBlobFs) IsNotSupported(err error) bool { + if err == nil { + return false + } + return err == ErrVfsUnsupported +} + // CheckRootPath creates the specified local root directory if it does not exists func (fs *AzureBlobFs) CheckRootPath(username string, uid int, gid int) bool { // we need a local directory for temporary files @@ -575,7 +580,7 @@ func (fs *AzureBlobFs) ScanRootDirContents() (int, int64, error) { // GetDirSize returns the number of files and the size for a folder // including any subfolders func (*AzureBlobFs) GetDirSize(dirname string) (int, int64, error) { - return 0, 0, errUnsupported + return 0, 0, ErrVfsUnsupported } // GetAtomicUploadPath returns the path to use for an atomic upload. diff --git a/vfs/gcsfs.go b/vfs/gcsfs.go index 5f969dde..d5c6350c 100644 --- a/vfs/gcsfs.go +++ b/vfs/gcsfs.go @@ -314,37 +314,34 @@ func (fs *GCSFs) Mkdir(name string) error { // Symlink creates source as a symbolic link to target. func (*GCSFs) Symlink(source, target string) error { - return errors.New("403 symlinks are not supported") + return ErrVfsUnsupported } // Readlink returns the destination of the named symbolic link func (*GCSFs) Readlink(name string) (string, error) { - return "", errors.New("403 readlink is not supported") + return "", ErrVfsUnsupported } // Chown changes the numeric uid and gid of the named file. -// Silently ignored. func (*GCSFs) Chown(name string, uid int, gid int) error { - return nil + return ErrVfsUnsupported } // Chmod changes the mode of the named file to mode. -// Silently ignored. func (*GCSFs) Chmod(name string, mode os.FileMode) error { - return nil + return ErrVfsUnsupported } // Chtimes changes the access and modification times of the named file. -// Silently ignored. func (*GCSFs) Chtimes(name string, atime, mtime time.Time) error { - return errors.New("403 chtimes is not supported") + return ErrVfsUnsupported } // Truncate changes the size of the named file. // Truncate by path is not supported, while truncating an opened // file is handled inside base transfer func (*GCSFs) Truncate(name string, size int64) error { - return errors.New("403 truncate is not supported") + return ErrVfsUnsupported } // ReadDir reads the directory named by dirname and returns @@ -455,6 +452,14 @@ func (*GCSFs) IsPermission(err error) bool { return strings.Contains(err.Error(), "403") } +// IsNotSupported returns true if the error indicate an unsupported operation +func (*GCSFs) IsNotSupported(err error) bool { + if err == nil { + return false + } + return err == ErrVfsUnsupported +} + // CheckRootPath creates the specified local root directory if it does not exists func (fs *GCSFs) CheckRootPath(username string, uid int, gid int) bool { // we need a local directory for temporary files @@ -502,7 +507,7 @@ func (fs *GCSFs) ScanRootDirContents() (int, int64, error) { // GetDirSize returns the number of files and the size for a folder // including any subfolders func (*GCSFs) GetDirSize(dirname string) (int, int64, error) { - return 0, 0, errUnsupported + return 0, 0, ErrVfsUnsupported } // GetAtomicUploadPath returns the path to use for an atomic upload. diff --git a/vfs/osfs.go b/vfs/osfs.go index 20583a21..c01cc2eb 100644 --- a/vfs/osfs.go +++ b/vfs/osfs.go @@ -185,6 +185,14 @@ func (*OsFs) IsPermission(err error) bool { return os.IsPermission(err) } +// IsNotSupported returns true if the error indicate an unsupported operation +func (*OsFs) IsNotSupported(err error) bool { + if err == nil { + return false + } + return err == ErrVfsUnsupported +} + // CheckRootPath creates the root directory if it does not exists func (fs *OsFs) CheckRootPath(username string, uid int, gid int) bool { var err error diff --git a/vfs/s3fs.go b/vfs/s3fs.go index 74af5c6b..cd8c3317 100644 --- a/vfs/s3fs.go +++ b/vfs/s3fs.go @@ -350,37 +350,34 @@ func (fs *S3Fs) Mkdir(name string) error { // Symlink creates source as a symbolic link to target. func (*S3Fs) Symlink(source, target string) error { - return errors.New("403 symlinks are not supported") + return ErrVfsUnsupported } // Readlink returns the destination of the named symbolic link func (*S3Fs) Readlink(name string) (string, error) { - return "", errors.New("403 readlink is not supported") + return "", ErrVfsUnsupported } // Chown changes the numeric uid and gid of the named file. -// Silently ignored. func (*S3Fs) Chown(name string, uid int, gid int) error { - return nil + return ErrVfsUnsupported } // Chmod changes the mode of the named file to mode. -// Silently ignored. func (*S3Fs) Chmod(name string, mode os.FileMode) error { - return nil + return ErrVfsUnsupported } // Chtimes changes the access and modification times of the named file. -// Silently ignored. func (*S3Fs) Chtimes(name string, atime, mtime time.Time) error { - return errors.New("403 chtimes is not supported") + return ErrVfsUnsupported } // Truncate changes the size of the named file. // Truncate by path is not supported, while truncating an opened // file is handled inside base transfer func (*S3Fs) Truncate(name string, size int64) error { - return errors.New("403 truncate is not supported") + return ErrVfsUnsupported } // ReadDir reads the directory named by dirname and returns @@ -485,6 +482,14 @@ func (*S3Fs) IsPermission(err error) bool { return strings.Contains(err.Error(), "403") } +// IsNotSupported returns true if the error indicate an unsupported operation +func (*S3Fs) IsNotSupported(err error) bool { + if err == nil { + return false + } + return err == ErrVfsUnsupported +} + // CheckRootPath creates the specified local root directory if it does not exists func (fs *S3Fs) CheckRootPath(username string, uid int, gid int) bool { // we need a local directory for temporary files @@ -520,7 +525,7 @@ func (fs *S3Fs) ScanRootDirContents() (int, int64, error) { // GetDirSize returns the number of files and the size for a folder // including any subfolders func (*S3Fs) GetDirSize(dirname string) (int, int64, error) { - return 0, 0, errUnsupported + return 0, 0, ErrVfsUnsupported } // GetAtomicUploadPath returns the path to use for an atomic upload. diff --git a/vfs/vfs.go b/vfs/vfs.go index 89c0ee4d..c9086ff3 100644 --- a/vfs/vfs.go +++ b/vfs/vfs.go @@ -46,6 +46,7 @@ type Fs interface { ResolvePath(sftpPath string) (string, error) IsNotExist(err error) bool IsPermission(err error) bool + IsNotSupported(err error) bool ScanRootDirContents() (int, int64, error) GetDirSize(dirname string) (int, int64, error) GetAtomicUploadPath(name string) string @@ -56,7 +57,7 @@ type Fs interface { GetMimeType(name string) (string, error) } -var errUnsupported = errors.New("Not supported") +var ErrVfsUnsupported = errors.New("Not supported") // QuotaCheckResult defines the result for a quota check type QuotaCheckResult struct {