mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 15:10:23 +00:00
add basic S3-Compatible Object Storage support
we have now an interface for filesystem backeds, this make easy to add new filesystem backends
This commit is contained in:
parent
0b42dbc3c3
commit
a4834f4a83
40 changed files with 2315 additions and 420 deletions
|
@ -11,7 +11,7 @@ env:
|
|||
- GO111MODULE=on
|
||||
|
||||
before_script:
|
||||
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" TEXT NULL);'
|
||||
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" TEXT NULL, "filesystem" text NULL);'
|
||||
|
||||
install:
|
||||
- go get -v -t ./...
|
||||
|
|
58
README.md
58
README.md
|
@ -21,6 +21,7 @@ Full featured and highly configurable SFTP server
|
|||
- Atomic uploads are configurable.
|
||||
- Support for Git repositories over SSH.
|
||||
- SCP and rsync are supported.
|
||||
- Support for serving S3 Compatible Object Storage over SFTP.
|
||||
- Prometheus metrics are exposed.
|
||||
- REST API for users management, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection.
|
||||
- Web based interface to easily manage users and connections.
|
||||
|
@ -136,7 +137,7 @@ The `sftpgo` configuration file contains the following sections:
|
|||
- `idle_timeout`, integer. Time in minutes after which an idle client will be disconnected. 0 menas disabled. Default: 15
|
||||
- `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts are unlimited. If set to zero, the number of attempts are limited to 6.
|
||||
- `umask`, string. Umask for the new files and directories. This setting has no effect on Windows. Default: "0022"
|
||||
- `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default "SFTPGo_version"
|
||||
- `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default "SFTPGo_<version>"
|
||||
- `upload_mode` integer. 0 means standard, the files are uploaded directly to the requested path. 1 means atomic: files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoids problems such as a web server that serves partial files when the files are being uploaded. In atomic mode if there is an upload error the temporary file is deleted and so the requested upload path will not contain a partial file. 2 means atomic with resume support: as atomic but if there is an upload error the temporary file is renamed to the requested path and not deleted, this way a client can reconnect and resume the upload.
|
||||
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details
|
||||
- `execute_on`, list of strings. Valid values are `download`, `upload`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions.
|
||||
|
@ -150,7 +151,7 @@ The `sftpgo` configuration file contains the following sections:
|
|||
- `macs`, list of strings. available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L84 "Supported MACs")
|
||||
- `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 send no login banner
|
||||
- `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored.
|
||||
- `enabled_ssh_commands`, list of enabled SSH commands. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`. `*` 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 quota check is suboptimal: if quota is enabled, the number of files is checked at the command begin 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 send 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 to disable 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. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`. `*` 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 remote filesystems, such as S3, and quota check is suboptimal: if quota is enabled, the number of files is checked at the command begin 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 send 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 to disable quota restrictions and periodically update quota usage yourself using the REST API. We support the following SSH commands:
|
||||
- `scp`, SCP is an experimental feature, we have our own SCP implementation since we can't rely on "scp" system command to proper handle quotas and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedbacks before enabling it by default. We may not handle some borderline cases or have sneaky bugs. Please do accurate tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. 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 does not support the SFTP SSH_FXP_REALPATH packet type and so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` do nothing and `pwd` always returns the `/` path.
|
||||
|
@ -416,6 +417,25 @@ The `http_notification_url`, if defined, will be called invoked as http POST. Th
|
|||
|
||||
The HTTP request has a 15 seconds timeout.
|
||||
|
||||
## S3 Compabible Object Storage backends
|
||||
|
||||
Each user can be mapped with an S3-Compatible bucket, this way the mapped bucket is exposed over SFTP/SCP.
|
||||
SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3 and automatically try to create the mapped bucket if it does not exists.
|
||||
|
||||
Some SFTP commands doesn't work over S3:
|
||||
|
||||
- `symlink` and `chtimes` will fail
|
||||
- `chown`, `chmod` are silently ignored
|
||||
- upload resume is not supported
|
||||
- upload mode `atomic` is ignored since S3 uploads are already atomic
|
||||
|
||||
Other notes:
|
||||
|
||||
- `rename` is a two steps operation: server-side copy and then deletion. So it is not atomic as for local filesystem
|
||||
- We don't support renaming non empty directories since we should rename all the contents too and this could take long time: think about directories with thousands of files, for each file we should do an AWS API call.
|
||||
- For server side encryption you have to configure the mapped bucket to automatically encrypt objects.
|
||||
- A local home directory is still required to store temporary files.
|
||||
|
||||
## Portable mode
|
||||
|
||||
SFTPGo allows to share a single directory on demand using the `portable` subcommand:
|
||||
|
@ -432,17 +452,24 @@ Usage:
|
|||
sftpgo portable [flags]
|
||||
|
||||
Flags:
|
||||
-C, --advertise-credentials If the SFTP service is advertised via multicast DNS this flag allows to put username/password inside the advertised TXT record
|
||||
-S, --advertise-service Advertise SFTP service using multicast DNS (default true)
|
||||
-d, --directory string Path to the directory to serve. This can be an absolute path or a path relative to the current directory (default ".")
|
||||
-h, --help help for portable
|
||||
-l, --log-file-path string Leave empty to disable logging
|
||||
-p, --password string Leave empty to use an auto generated value
|
||||
-g, --permissions strings User's permissions. "*" means any permission (default [list,download])
|
||||
-C, --advertise-credentials If the SFTP service is advertised via multicast DNS this flag allows to put username/password inside the advertised TXT record
|
||||
-S, --advertise-service Advertise SFTP service using multicast DNS (default true)
|
||||
-d, --directory string Path to the directory to serve. This can be an absolute path or a path relative to the current directory (default ".")
|
||||
-f, --fs-provider int 0 means local filesystem, 1 S3 compatible
|
||||
-h, --help help for portable
|
||||
-l, --log-file-path string Leave empty to disable logging
|
||||
-p, --password string Leave empty to use an auto generated value
|
||||
-g, --permissions strings User's permissions. "*" means any permission (default [list,download])
|
||||
-k, --public-key strings
|
||||
-s, --sftpd-port int 0 means a random non privileged port
|
||||
-c, --ssh-commands strings SSH commands to enable. "*" means any supported SSH command including scp (default [md5sum,sha1sum,cd,pwd])
|
||||
-u, --username string Leave empty to use an auto generated value
|
||||
--s3-access-key string
|
||||
--s3-access-secret string
|
||||
--s3-bucket string
|
||||
--s3-endpoint string
|
||||
--s3-region string
|
||||
--s3-storage-class string
|
||||
-s, --sftpd-port int 0 means a random non privileged port
|
||||
-c, --ssh-commands strings SSH commands to enable. "*" means any supported SSH command including scp (default [md5sum,sha1sum,cd,pwd])
|
||||
-u, --username string Leave empty to use an auto generated value
|
||||
```
|
||||
|
||||
In portable mode SFTPGo can advertise the SFTP service and, optionally, the credentials via multicast DNS, so there is a standard way to discover the service and to automatically connect to it.
|
||||
|
@ -488,6 +515,13 @@ For each account the following properties can be configured:
|
|||
- `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited.
|
||||
- `allowed_ip`, List of IP/Mask allowed to login. Any IP address not contained in this list cannot login. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32"
|
||||
- `denied_ip`, List of IP/Mask not allowed to login. If an IP address is both allowed and denied then login will be denied
|
||||
- `fs_provider`, filesystem to serve via SFTP. Local filesystem and S3 Compatible Object Storage are supported
|
||||
- `s3_bucket`, required for S3 filesystem
|
||||
- `s3_region`, required for S3 filesystem
|
||||
- `s3_access_key`, required for S3 filesystem
|
||||
- `s3_access_secret`, required for S3 filesystem. It is stored encrypted (AES-256-GCM)
|
||||
- `s3_endpoint`, specifies s3 endpoint (server) different from AWS
|
||||
- `s3_storage_class`
|
||||
|
||||
These properties are stored inside the data provider.
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/service"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
@ -20,6 +21,13 @@ var (
|
|||
portablePublicKeys []string
|
||||
portablePermissions []string
|
||||
portableSSHCommands []string
|
||||
portableFsProvider int
|
||||
portableS3Bucket string
|
||||
portableS3Region string
|
||||
portableS3AccessKey string
|
||||
portableS3AccessSecret string
|
||||
portableS3Endpoint string
|
||||
portableS3StorageClass string
|
||||
portableCmd = &cobra.Command{
|
||||
Use: "portable",
|
||||
Short: "Serve a single directory",
|
||||
|
@ -53,6 +61,17 @@ Please take a look at the usage below to customize the serving parameters`,
|
|||
Permissions: permissions,
|
||||
HomeDir: portableDir,
|
||||
Status: 1,
|
||||
FsConfig: dataprovider.Filesystem{
|
||||
Provider: portableFsProvider,
|
||||
S3Config: vfs.S3FsConfig{
|
||||
Bucket: portableS3Bucket,
|
||||
Region: portableS3Region,
|
||||
AccessKey: portableS3AccessKey,
|
||||
AccessSecret: portableS3AccessSecret,
|
||||
Endpoint: portableS3Endpoint,
|
||||
StorageClass: portableS3StorageClass,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := service.StartPortableMode(portableSFTPDPort, portableSSHCommands, portableAdvertiseService,
|
||||
|
@ -79,5 +98,12 @@ func init() {
|
|||
"Advertise SFTP service using multicast DNS")
|
||||
portableCmd.Flags().BoolVarP(&portableAdvertiseCredentials, "advertise-credentials", "C", false,
|
||||
"If the SFTP service is advertised via multicast DNS this flag allows to put username/password inside the advertised TXT record")
|
||||
portableCmd.Flags().IntVarP(&portableFsProvider, "fs-provider", "f", 0, "0 means local filesystem, 1 S3 compatible")
|
||||
portableCmd.Flags().StringVar(&portableS3Bucket, "s3-bucket", "", "")
|
||||
portableCmd.Flags().StringVar(&portableS3Region, "s3-region", "", "")
|
||||
portableCmd.Flags().StringVar(&portableS3AccessKey, "s3-access-key", "", "")
|
||||
portableCmd.Flags().StringVar(&portableS3AccessSecret, "s3-access-secret", "", "")
|
||||
portableCmd.Flags().StringVar(&portableS3Endpoint, "s3-endpoint", "", "")
|
||||
portableCmd.Flags().StringVar(&portableS3StorageClass, "s3-storage-class", "", "")
|
||||
rootCmd.AddCommand(portableCmd)
|
||||
}
|
||||
|
|
|
@ -336,7 +336,7 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str
|
|||
if offset == 0 {
|
||||
user, err := p.userExists(username)
|
||||
if err == nil {
|
||||
users = append(users, getUserNoCredentials(&user))
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
}
|
||||
}
|
||||
return users, err
|
||||
|
@ -357,7 +357,7 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str
|
|||
var user User
|
||||
err = json.Unmarshal(v, &user)
|
||||
if err == nil {
|
||||
users = append(users, getUserNoCredentials(&user))
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
}
|
||||
if len(users) >= limit {
|
||||
break
|
||||
|
@ -372,7 +372,7 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str
|
|||
var user User
|
||||
err = json.Unmarshal(v, &user)
|
||||
if err == nil {
|
||||
users = append(users, getUserNoCredentials(&user))
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
}
|
||||
if len(users) >= limit {
|
||||
break
|
||||
|
@ -388,11 +388,6 @@ func (p BoltProvider) close() error {
|
|||
return p.dbHandle.Close()
|
||||
}
|
||||
|
||||
func getUserNoCredentials(user *User) User {
|
||||
user.Password = ""
|
||||
return *user
|
||||
}
|
||||
|
||||
// itob returns an 8-byte big endian representation of v.
|
||||
func itob(v int64) []byte {
|
||||
b := make([]byte, 8)
|
||||
|
|
|
@ -34,6 +34,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/metrics"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
unixcrypt "github.com/nathanaelle/password"
|
||||
)
|
||||
|
||||
|
@ -479,6 +480,27 @@ func validateFilters(user *User) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateFilesystemConfig(user *User) error {
|
||||
if user.FsConfig.Provider == 1 {
|
||||
err := vfs.ValidateS3FsConfig(&user.FsConfig.S3Config)
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could not validate s3config: %v", err)}
|
||||
}
|
||||
vals := strings.Split(user.FsConfig.S3Config.AccessSecret, "$")
|
||||
if !strings.HasPrefix(user.FsConfig.S3Config.AccessSecret, "$aes$") || len(vals) != 4 {
|
||||
accessSecret, err := utils.EncryptData(user.FsConfig.S3Config.AccessSecret)
|
||||
if err != nil {
|
||||
return &ValidationError{err: fmt.Sprintf("Could encrypt s3 access secret: %v", err)}
|
||||
}
|
||||
user.FsConfig.S3Config.AccessSecret = accessSecret
|
||||
}
|
||||
return nil
|
||||
}
|
||||
user.FsConfig.Provider = 0
|
||||
user.FsConfig.S3Config = vfs.S3FsConfig{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUser(user *User) error {
|
||||
buildUserHomeDir(user)
|
||||
if len(user.Username) == 0 || len(user.HomeDir) == 0 {
|
||||
|
@ -493,6 +515,9 @@ func validateUser(user *User) error {
|
|||
if err := validatePermissions(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFilesystemConfig(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Status < 0 || user.Status > 1 {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)}
|
||||
}
|
||||
|
@ -645,6 +670,15 @@ func comparePbkdf2PasswordAndHash(password, hashedPassword string) (bool, error)
|
|||
return subtle.ConstantTimeCompare(buf, []byte(expected)) == 1, nil
|
||||
}
|
||||
|
||||
// HideUserSensitiveData hides user sensitive data
|
||||
func HideUserSensitiveData(user *User) User {
|
||||
user.Password = ""
|
||||
if user.FsConfig.Provider == 1 {
|
||||
user.FsConfig.S3Config.AccessSecret = utils.RemoveDecryptionKey(user.FsConfig.S3Config.AccessSecret)
|
||||
}
|
||||
return *user
|
||||
}
|
||||
|
||||
func getSSLMode() string {
|
||||
if config.Driver == PGSQLDataProviderName {
|
||||
if config.SSLMode == 0 {
|
||||
|
@ -772,8 +806,7 @@ func executeAction(operation string, user User) {
|
|||
return
|
||||
}
|
||||
}
|
||||
// hide the hashed password
|
||||
user.Password = ""
|
||||
HideUserSensitiveData(&user)
|
||||
if len(config.Actions.Command) > 0 && filepath.IsAbs(config.Actions.Command) {
|
||||
// we are in a goroutine but if we have to send an HTTP notification we don't want to wait for the
|
||||
// end of the command
|
||||
|
|
|
@ -244,8 +244,7 @@ func (p MemoryProvider) getUsers(limit int, offset int, order string, username s
|
|||
if offset == 0 {
|
||||
user, err := p.userExistsInternal(username)
|
||||
if err == nil {
|
||||
user.Password = ""
|
||||
users = append(users, user)
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
}
|
||||
}
|
||||
return users, err
|
||||
|
@ -258,8 +257,7 @@ func (p MemoryProvider) getUsers(limit int, offset int, order string, username s
|
|||
continue
|
||||
}
|
||||
user := p.dbHandle.users[username]
|
||||
user.Password = ""
|
||||
users = append(users, user)
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
if len(users) >= limit {
|
||||
break
|
||||
}
|
||||
|
@ -272,8 +270,7 @@ func (p MemoryProvider) getUsers(limit int, offset int, order string, username s
|
|||
}
|
||||
username := p.dbHandle.usernames[i]
|
||||
user := p.dbHandle.users[username]
|
||||
user.Password = ""
|
||||
users = append(users, user)
|
||||
users = append(users, HideUserSensitiveData(&user))
|
||||
if len(users) >= limit {
|
||||
break
|
||||
}
|
||||
|
|
|
@ -162,8 +162,13 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fsConfig, err := user.GetFsConfigAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters))
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters),
|
||||
string(fsConfig))
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -191,9 +196,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fsConfig, err := user.GetFsConfigAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
|
||||
string(filters), user.ID)
|
||||
string(filters), string(fsConfig), user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -253,10 +262,8 @@ func sqlCommonGetUsers(limit int, offset int, order string, username string, dbH
|
|||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
u, err := getUserFromDbRow(nil, rows)
|
||||
// hide password
|
||||
if err == nil {
|
||||
u.Password = ""
|
||||
users = append(users, u)
|
||||
users = append(users, HideUserSensitiveData(&u))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
@ -272,16 +279,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
var password sql.NullString
|
||||
var publicKey sql.NullString
|
||||
var filters sql.NullString
|
||||
var fsConfig sql.NullString
|
||||
var err error
|
||||
if row != nil {
|
||||
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
|
||||
|
||||
} else {
|
||||
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
|
||||
}
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
@ -326,5 +334,16 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
DeniedIP: []string{},
|
||||
}
|
||||
}
|
||||
if fsConfig.Valid {
|
||||
var fs Filesystem
|
||||
err = json.Unmarshal([]byte(fsConfig.String), &fs)
|
||||
if err == nil {
|
||||
user.FsConfig = fs
|
||||
}
|
||||
} else {
|
||||
user.FsConfig = Filesystem{
|
||||
Provider: 0,
|
||||
}
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import "fmt"
|
|||
|
||||
const (
|
||||
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
|
||||
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters"
|
||||
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem"
|
||||
)
|
||||
|
||||
func getSQLPlaceholders() []string {
|
||||
|
@ -60,19 +60,20 @@ func getQuotaQuery() string {
|
|||
|
||||
func getAddUserQuery() string {
|
||||
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
|
||||
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
|
||||
filesystem)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
|
||||
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13],
|
||||
sqlPlaceholders[14])
|
||||
sqlPlaceholders[14], sqlPlaceholders[15])
|
||||
}
|
||||
|
||||
func getUpdateUserQuery() string {
|
||||
return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v WHERE id = %v`,
|
||||
config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
|
||||
sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10],
|
||||
sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14])
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v
|
||||
WHERE id = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
|
||||
sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
|
||||
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15])
|
||||
}
|
||||
|
||||
func getDeleteUserQuery() string {
|
||||
|
|
|
@ -7,10 +7,10 @@ import (
|
|||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
// Available permissions for SFTP users
|
||||
|
@ -53,6 +53,13 @@ type UserFilters struct {
|
|||
DeniedIP []string `json:"denied_ip"`
|
||||
}
|
||||
|
||||
// Filesystem defines cloud storage filesystem details
|
||||
type Filesystem struct {
|
||||
// 0 local filesystem, 1 Amazon S3 compatible
|
||||
Provider int `json:"provider"`
|
||||
S3Config vfs.S3FsConfig `json:"s3config,omitempty"`
|
||||
}
|
||||
|
||||
// User defines an SFTP user
|
||||
type User struct {
|
||||
// Database unique identifier
|
||||
|
@ -98,6 +105,16 @@ type User struct {
|
|||
LastLogin int64 `json:"last_login"`
|
||||
// Additional restrictions
|
||||
Filters UserFilters `json:"filters"`
|
||||
// Filesystem configuration details
|
||||
FsConfig Filesystem `json:"filesystem"`
|
||||
}
|
||||
|
||||
// GetFilesystem returns the filesystem for this user
|
||||
func (u *User) GetFilesystem(connectionID string) (vfs.Fs, error) {
|
||||
if u.FsConfig.Provider == 1 {
|
||||
return vfs.NewS3Fs(connectionID, u.GetHomeDir(), u.FsConfig.S3Config)
|
||||
}
|
||||
return vfs.NewOsFs(connectionID), nil
|
||||
}
|
||||
|
||||
// GetPermissionsForPath returns the permissions for the given path.
|
||||
|
@ -211,6 +228,11 @@ func (u *User) GetFiltersAsJSON() ([]byte, error) {
|
|||
return json.Marshal(u.Filters)
|
||||
}
|
||||
|
||||
// GetFsConfigAsJSON returns the filesystem config as json byte array
|
||||
func (u *User) GetFsConfigAsJSON() ([]byte, error) {
|
||||
return json.Marshal(u.FsConfig)
|
||||
}
|
||||
|
||||
// GetUID returns a validate uid, suitable for use with os.Chown
|
||||
func (u *User) GetUID() int {
|
||||
if u.UID <= 0 || u.UID > 65535 {
|
||||
|
@ -237,19 +259,6 @@ func (u *User) HasQuotaRestrictions() bool {
|
|||
return u.QuotaFiles > 0 || u.QuotaSize > 0
|
||||
}
|
||||
|
||||
// GetRelativePath returns the path for a file relative to the user's home dir.
|
||||
// This is the path as seen by SFTP users
|
||||
func (u *User) GetRelativePath(path string) string {
|
||||
rel, err := filepath.Rel(u.GetHomeDir(), filepath.Clean(path))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
rel = ""
|
||||
}
|
||||
return "/" + filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// GetQuotaSummary returns used quota and limits if defined
|
||||
func (u *User) GetQuotaSummary() string {
|
||||
var result string
|
||||
|
@ -319,6 +328,9 @@ func (u *User) GetInfoString() string {
|
|||
t := utils.GetTimeFromMsecSinceEpoch(u.LastLogin)
|
||||
result += fmt.Sprintf("Last login: %v ", t.Format("2006-01-02 15:04:05")) // YYYY-MM-DD HH:MM:SS
|
||||
}
|
||||
if u.FsConfig.Provider == 1 {
|
||||
result += fmt.Sprintf("Storage: S3")
|
||||
}
|
||||
if len(u.PublicKeys) > 0 {
|
||||
result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys))
|
||||
}
|
||||
|
@ -387,6 +399,17 @@ func (u *User) getACopy() User {
|
|||
copy(filters.AllowedIP, u.Filters.AllowedIP)
|
||||
filters.DeniedIP = make([]string, len(u.Filters.DeniedIP))
|
||||
copy(filters.DeniedIP, u.Filters.DeniedIP)
|
||||
fsConfig := Filesystem{
|
||||
Provider: u.FsConfig.Provider,
|
||||
S3Config: vfs.S3FsConfig{
|
||||
Bucket: u.FsConfig.S3Config.Bucket,
|
||||
Region: u.FsConfig.S3Config.Region,
|
||||
AccessKey: u.FsConfig.S3Config.AccessKey,
|
||||
AccessSecret: u.FsConfig.S3Config.AccessSecret,
|
||||
Endpoint: u.FsConfig.S3Config.Endpoint,
|
||||
StorageClass: u.FsConfig.S3Config.StorageClass,
|
||||
},
|
||||
}
|
||||
|
||||
return User{
|
||||
ID: u.ID,
|
||||
|
@ -409,6 +432,7 @@ func (u *User) getACopy() User {
|
|||
ExpirationDate: u.ExpirationDate,
|
||||
LastLogin: u.LastLogin,
|
||||
Filters: filters,
|
||||
FsConfig: fsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
4
go.mod
4
go.mod
|
@ -4,7 +4,9 @@ go 1.13
|
|||
|
||||
require (
|
||||
github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802
|
||||
github.com/aws/aws-sdk-go v1.28.3
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/eikenb/pipeat v0.0.0-20190316224601-fb1f3a9aa29f
|
||||
github.com/go-chi/chi v4.0.2+incompatible
|
||||
github.com/go-chi/render v1.0.1
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
|
@ -24,3 +26,5 @@ require (
|
|||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
)
|
||||
|
||||
replace github.com/eikenb/pipeat v0.0.0-20190316224601-fb1f3a9aa29f => github.com/drakkan/pipeat v0.0.0-20200114135659-fac71c64d75d
|
||||
|
|
18
go.sum
18
go.sum
|
@ -1,5 +1,4 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
|
@ -9,6 +8,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
|
|||
github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802 h1:RwMM1q/QSKYIGbHfOkf843hE8sSUJtf1dMwFPtEDmm0=
|
||||
github.com/alexedwards/argon2id v0.0.0-20190612080829-01a59b2b8802/go.mod h1:4dsm7ufQm1Gwl8S2ss57u+2J7KlxIL2QUmFGlGtWogY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/aws/aws-sdk-go v1.28.3 h1:FnkDp+fz4JHWUW3Ust2Wh89RpdGif077Wjis/sMrGKM=
|
||||
github.com/aws/aws-sdk-go v1.28.3/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
@ -32,6 +33,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/drakkan/pipeat v0.0.0-20200114135659-fac71c64d75d h1:+k0oy9bBY9dXlKHriYg6crXpwIrtM1rCrlUehmc/F3M=
|
||||
github.com/drakkan/pipeat v0.0.0-20200114135659-fac71c64d75d/go.mod h1:wNYvIpR5rIhoezOYcpxcXz4HbIEOu7A45EqlQCA+h+w=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
|
@ -57,10 +60,8 @@ github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs
|
|||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/grandcat/zeroconf v0.0.0-20190424104450-85eadb44205c h1:svzQzfVE9t7Y1CGULS5PsMWs4/H4Au/ZTJzU/0CKgqc=
|
||||
|
@ -70,12 +71,12 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgf
|
|||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
|
@ -84,10 +85,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
|||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
|
||||
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
|
@ -148,9 +147,7 @@ github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF
|
|||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
|
@ -205,7 +202,6 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
|
@ -214,7 +210,6 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -244,7 +239,6 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi
|
|||
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
)
|
||||
|
||||
func dumpData(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -103,10 +104,16 @@ func loadData(w http.ResponseWriter, r *http.Request) {
|
|||
u, err := dataprovider.UserExists(dataProvider, user.Username)
|
||||
if err == nil {
|
||||
user.ID = u.ID
|
||||
user.LastLogin = u.LastLogin
|
||||
user.UsedQuotaSize = u.UsedQuotaSize
|
||||
user.UsedQuotaFiles = u.UsedQuotaFiles
|
||||
err = dataprovider.UpdateUser(dataProvider, user)
|
||||
user.Password = "[redacted]"
|
||||
logger.Debug(logSender, "", "restoring existing user: %+v, dump file: %#v, error: %v", user, inputFile, err)
|
||||
} else {
|
||||
user.LastLogin = 0
|
||||
user.UsedQuotaSize = 0
|
||||
user.UsedQuotaFiles = 0
|
||||
err = dataprovider.AddUser(dataProvider, user)
|
||||
user.Password = "[redacted]"
|
||||
logger.Debug(logSender, "", "adding new user: %+v, dump file: %#v, error: %v", user, inputFile, err)
|
||||
|
@ -115,11 +122,17 @@ func loadData(w http.ResponseWriter, r *http.Request) {
|
|||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
}
|
||||
if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) {
|
||||
logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username)
|
||||
doQuotaScan(user)
|
||||
if needQuotaScan(scanQuota, &user) {
|
||||
if sftpd.AddQuotaScan(user.Username) {
|
||||
logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username)
|
||||
go doQuotaScan(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debug(logSender, "", "backup restored, users: %v", len(dump.Users))
|
||||
sendAPIResponse(w, r, err, "Data restored", http.StatusOK)
|
||||
}
|
||||
|
||||
func needQuotaScan(scanQuota int, user *dataprovider.User) bool {
|
||||
return scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions())
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
|
@ -26,26 +25,27 @@ func startQuotaScan(w http.ResponseWriter, r *http.Request) {
|
|||
sendAPIResponse(w, r, err, "", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if doQuotaScan(user) {
|
||||
if sftpd.AddQuotaScan(user.Username) {
|
||||
go doQuotaScan(user)
|
||||
sendAPIResponse(w, r, err, "Scan started", http.StatusCreated)
|
||||
} else {
|
||||
sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func doQuotaScan(user dataprovider.User) bool {
|
||||
result := sftpd.AddQuotaScan(user.Username)
|
||||
if result {
|
||||
go func() {
|
||||
numFiles, size, _, err := utils.ScanDirContents(user.HomeDir)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "error scanning user home dir %#v: %v", user.HomeDir, err)
|
||||
} else {
|
||||
err := dataprovider.UpdateUserQuota(dataProvider, user, numFiles, size, true)
|
||||
logger.Debug(logSender, "", "user home dir scanned, user: %#v, dir: %#v, error: %v", user.Username, user.HomeDir, err)
|
||||
}
|
||||
sftpd.RemoveQuotaScan(user.Username)
|
||||
}()
|
||||
func doQuotaScan(user dataprovider.User) error {
|
||||
defer sftpd.RemoveQuotaScan(user.Username)
|
||||
fs, err := user.GetFilesystem("")
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "unable scan quota for user %#v error creating filesystem: %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
return result
|
||||
numFiles, size, err := fs.ScanDirContents(user.HomeDir)
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "error scanning user home dir %#v: %v", user.HomeDir, err)
|
||||
} else {
|
||||
err = dataprovider.UpdateUserQuota(dataProvider, user, numFiles, size, true)
|
||||
logger.Debug(logSender, "", "user home dir scanned, user: %#v, dir: %#v, error: %v", user.Username, user.HomeDir, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
@ -63,8 +64,7 @@ func getUserByID(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
user, err := dataprovider.GetUserByID(dataProvider, userID)
|
||||
if err == nil {
|
||||
user.Password = ""
|
||||
render.JSON(w, r, user)
|
||||
render.JSON(w, r, dataprovider.HideUserSensitiveData(&user))
|
||||
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||
sendAPIResponse(w, r, err, "", http.StatusNotFound)
|
||||
} else {
|
||||
|
@ -83,8 +83,7 @@ func addUser(w http.ResponseWriter, r *http.Request) {
|
|||
if err == nil {
|
||||
user, err = dataprovider.UserExists(dataProvider, user.Username)
|
||||
if err == nil {
|
||||
user.Password = ""
|
||||
render.JSON(w, r, user)
|
||||
render.JSON(w, r, dataprovider.HideUserSensitiveData(&user))
|
||||
} else {
|
||||
sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
|
||||
}
|
||||
|
@ -102,6 +101,10 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
user, err := dataprovider.GetUserByID(dataProvider, userID)
|
||||
oldPermissions := user.Permissions
|
||||
oldS3AccessSecret := ""
|
||||
if user.FsConfig.Provider == 1 {
|
||||
oldS3AccessSecret = user.FsConfig.S3Config.AccessSecret
|
||||
}
|
||||
user.Permissions = make(map[string][]string)
|
||||
if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
|
||||
sendAPIResponse(w, r, err, "", http.StatusNotFound)
|
||||
|
@ -119,6 +122,13 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if len(user.Permissions) == 0 {
|
||||
user.Permissions = oldPermissions
|
||||
}
|
||||
// we use the new access secret if different from the old one and not empty
|
||||
if user.FsConfig.Provider == 1 {
|
||||
if utils.RemoveDecryptionKey(oldS3AccessSecret) == user.FsConfig.S3Config.AccessSecret ||
|
||||
len(user.FsConfig.S3Config.AccessSecret) == 0 {
|
||||
user.FsConfig.S3Config.AccessSecret = oldS3AccessSecret
|
||||
}
|
||||
}
|
||||
if user.ID != userID {
|
||||
sendAPIResponse(w, r, err, "user ID in request body does not match user ID in path parameter", http.StatusBadRequest)
|
||||
return
|
||||
|
|
|
@ -406,10 +406,66 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
|
|||
if err := compareUserFilters(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := compareUserFsConfig(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return compareEqualsUserFields(expected, actual)
|
||||
}
|
||||
|
||||
func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if expected.FsConfig.Provider != actual.FsConfig.Provider {
|
||||
return errors.New("Fs provider mismatch")
|
||||
}
|
||||
if expected.FsConfig.S3Config.Bucket != actual.FsConfig.S3Config.Bucket {
|
||||
return errors.New("S3 bucket mismatch")
|
||||
}
|
||||
if expected.FsConfig.S3Config.Region != actual.FsConfig.S3Config.Region {
|
||||
return errors.New("S3 region mismatch")
|
||||
}
|
||||
if expected.FsConfig.S3Config.AccessKey != actual.FsConfig.S3Config.AccessKey {
|
||||
return errors.New("S3 access key mismatch")
|
||||
}
|
||||
if err := checkS3AccessSecret(expected.FsConfig.S3Config.AccessSecret, actual.FsConfig.S3Config.AccessSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
if expected.FsConfig.S3Config.Endpoint != actual.FsConfig.S3Config.Endpoint {
|
||||
return errors.New("S3 endpoint mismatch")
|
||||
}
|
||||
if expected.FsConfig.S3Config.StorageClass != actual.FsConfig.S3Config.StorageClass {
|
||||
return errors.New("S3 storage class mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkS3AccessSecret(expectedAccessSecret, actualAccessSecret string) error {
|
||||
if len(expectedAccessSecret) > 0 {
|
||||
vals := strings.Split(expectedAccessSecret, "$")
|
||||
if strings.HasPrefix(expectedAccessSecret, "$aes$") && len(vals) == 4 {
|
||||
expectedAccessSecret = utils.RemoveDecryptionKey(expectedAccessSecret)
|
||||
if expectedAccessSecret != actualAccessSecret {
|
||||
return fmt.Errorf("S3 access secret mismatch, expected: %v", expectedAccessSecret)
|
||||
}
|
||||
} else {
|
||||
// here we check that actualAccessSecret is aes encrypted without the nonce
|
||||
parts := strings.Split(actualAccessSecret, "$")
|
||||
if !strings.HasPrefix(actualAccessSecret, "$aes$") || len(parts) != 3 {
|
||||
return errors.New("Invalid S3 access secret")
|
||||
}
|
||||
if len(parts) == len(vals) {
|
||||
if expectedAccessSecret != actualAccessSecret {
|
||||
return errors.New("S3 encrypted access secret mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if expectedAccessSecret != actualAccessSecret {
|
||||
return errors.New("S3 access secret mismatch")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compareUserFilters(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if len(expected.Filters.AllowedIP) != len(actual.Filters.AllowedIP) {
|
||||
return errors.New("AllowedIP mismatch")
|
||||
|
|
|
@ -242,6 +242,16 @@ func TestAddUserInvalidFilters(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddUserInvalidFsConfig(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.FsConfig.Provider = 1
|
||||
u.FsConfig.S3Config.Bucket = ""
|
||||
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid fs config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPublicKey(t *testing.T) {
|
||||
u := getTestUser()
|
||||
invalidPubKey := "invalid"
|
||||
|
@ -299,6 +309,48 @@ func TestUpdateUser(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUserS3Config(t *testing.T) {
|
||||
user, _, err := httpd.AddUser(getTestUser(), http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
user.FsConfig.Provider = 1
|
||||
user.FsConfig.S3Config.Bucket = "test"
|
||||
user.FsConfig.S3Config.Region = "us-east-1"
|
||||
user.FsConfig.S3Config.AccessKey = "Server-Access-Key"
|
||||
user.FsConfig.S3Config.AccessSecret = "Server-Access-Secret"
|
||||
user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000"
|
||||
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to update user: %v", err)
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove: %v", err)
|
||||
}
|
||||
user.Password = defaultPassword
|
||||
user.ID = 0
|
||||
secret, _ := utils.EncryptData("Server-Access-Secret")
|
||||
user.FsConfig.S3Config.AccessSecret = secret
|
||||
user, _, err = httpd.AddUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
user.FsConfig.Provider = 1
|
||||
user.FsConfig.S3Config.Bucket = "test1"
|
||||
user.FsConfig.S3Config.Region = "us-east-1"
|
||||
user.FsConfig.S3Config.AccessKey = "Server-Access-Key1"
|
||||
user.FsConfig.S3Config.Endpoint = "http://localhost:9000"
|
||||
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to update user: %v", err)
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserNoCredentials(t *testing.T) {
|
||||
user, _, err := httpd.AddUser(getTestUser(), http.StatusOK)
|
||||
if err != nil {
|
||||
|
@ -1398,6 +1450,91 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWebUserS3Mock(t *testing.T) {
|
||||
user := getTestUser()
|
||||
userAsJSON := getUserAsJSON(t, user)
|
||||
req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer(userAsJSON))
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
err := render.DecodeJSON(rr.Body, &user)
|
||||
if err != nil {
|
||||
t.Errorf("Error get user: %v", err)
|
||||
}
|
||||
user.FsConfig.Provider = 1
|
||||
user.FsConfig.S3Config.Bucket = "test"
|
||||
user.FsConfig.S3Config.Region = "eu-west-1"
|
||||
user.FsConfig.S3Config.AccessKey = "access-key"
|
||||
user.FsConfig.S3Config.AccessSecret = "access-secret"
|
||||
user.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/path?a=b"
|
||||
user.FsConfig.S3Config.StorageClass = "Standard"
|
||||
form := make(url.Values)
|
||||
form.Set("username", user.Username)
|
||||
form.Set("home_dir", user.HomeDir)
|
||||
form.Set("uid", "0")
|
||||
form.Set("gid", strconv.FormatInt(int64(user.GID), 10))
|
||||
form.Set("max_sessions", strconv.FormatInt(int64(user.MaxSessions), 10))
|
||||
form.Set("quota_size", strconv.FormatInt(user.QuotaSize, 10))
|
||||
form.Set("quota_files", strconv.FormatInt(int64(user.QuotaFiles), 10))
|
||||
form.Set("upload_bandwidth", "0")
|
||||
form.Set("download_bandwidth", "0")
|
||||
form.Set("permissions", "*")
|
||||
form.Set("sub_dirs_permissions", "")
|
||||
form.Set("status", strconv.Itoa(user.Status))
|
||||
form.Set("expiration_date", "2020-01-01 00:00:00")
|
||||
form.Set("allowed_ip", "")
|
||||
form.Set("denied_ip", "")
|
||||
form.Set("fs_provider", "1")
|
||||
form.Set("s3_bucket", user.FsConfig.S3Config.Bucket)
|
||||
form.Set("s3_region", user.FsConfig.S3Config.Region)
|
||||
form.Set("s3_access_key", user.FsConfig.S3Config.AccessKey)
|
||||
form.Set("s3_access_secret", user.FsConfig.S3Config.AccessSecret)
|
||||
form.Set("s3_storage_class", user.FsConfig.S3Config.StorageClass)
|
||||
form.Set("s3_endpoint", user.FsConfig.S3Config.Endpoint)
|
||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusSeeOther, rr.Code)
|
||||
req, _ = http.NewRequest(http.MethodGet, userPath+"?limit=1&offset=0&order=ASC&username="+user.Username, nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
var users []dataprovider.User
|
||||
err = render.DecodeJSON(rr.Body, &users)
|
||||
if err != nil {
|
||||
t.Errorf("Error decoding users: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Errorf("1 user is expected")
|
||||
}
|
||||
updateUser := users[0]
|
||||
if updateUser.ExpirationDate != 1577836800000 {
|
||||
t.Errorf("invalid expiration date: %v", updateUser.ExpirationDate)
|
||||
}
|
||||
if updateUser.FsConfig.Provider != user.FsConfig.Provider {
|
||||
t.Error("fs provider mismatch")
|
||||
}
|
||||
if updateUser.FsConfig.S3Config.Bucket != user.FsConfig.S3Config.Bucket {
|
||||
t.Error("s3 bucket mismatch")
|
||||
}
|
||||
if updateUser.FsConfig.S3Config.Region != user.FsConfig.S3Config.Region {
|
||||
t.Error("s3 region mismatch")
|
||||
}
|
||||
if updateUser.FsConfig.S3Config.AccessKey != user.FsConfig.S3Config.AccessKey {
|
||||
t.Error("s3 access key mismatch")
|
||||
}
|
||||
if !strings.HasPrefix(updateUser.FsConfig.S3Config.AccessSecret, "$aes$") {
|
||||
t.Error("s3 access secret is not encrypted")
|
||||
}
|
||||
if updateUser.FsConfig.S3Config.StorageClass != user.FsConfig.S3Config.StorageClass {
|
||||
t.Error("s3 storage class mismatch")
|
||||
}
|
||||
if updateUser.FsConfig.S3Config.Endpoint != user.FsConfig.S3Config.Endpoint {
|
||||
t.Error("s3 endpoint mismatch")
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(user.ID, 10), nil)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestProviderClosedMock(t *testing.T) {
|
||||
if providerDriverName == dataprovider.BoltDataProviderName {
|
||||
t.Skip("skipping test provider errors for bolt provider")
|
||||
|
|
|
@ -6,9 +6,12 @@ import (
|
|||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
|
@ -120,6 +123,13 @@ func TestCheckUser(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Errorf("DeniedIP contents are not equal")
|
||||
}
|
||||
expected.Filters.DeniedIP = []string{}
|
||||
actual.Filters.DeniedIP = []string{}
|
||||
actual.FsConfig.Provider = 1
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("Fs providers are not equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareUserFields(t *testing.T) {
|
||||
|
@ -200,6 +210,72 @@ func TestCompareUserFields(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCompareUserFsConfig(t *testing.T) {
|
||||
expected := &dataprovider.User{}
|
||||
actual := &dataprovider.User{}
|
||||
expected.FsConfig.Provider = 1
|
||||
err := compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("Provider does not match")
|
||||
}
|
||||
expected.FsConfig.Provider = 0
|
||||
expected.FsConfig.S3Config.Bucket = "bucket"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 bucket does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.Bucket = ""
|
||||
expected.FsConfig.S3Config.Region = "region"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 region does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.Region = ""
|
||||
expected.FsConfig.S3Config.AccessKey = "access key"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 access key does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.AccessKey = ""
|
||||
actual.FsConfig.S3Config.AccessSecret = "access secret"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 access secret does not match")
|
||||
}
|
||||
secret, _ := utils.EncryptData("access secret")
|
||||
actual.FsConfig.S3Config.AccessSecret = ""
|
||||
expected.FsConfig.S3Config.AccessSecret = secret
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 access secret does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.AccessSecret = utils.RemoveDecryptionKey(secret)
|
||||
actual.FsConfig.S3Config.AccessSecret = utils.RemoveDecryptionKey(secret) + "a"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 access secret does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.AccessSecret = "test"
|
||||
actual.FsConfig.S3Config.AccessSecret = ""
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 access secret does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.AccessSecret = ""
|
||||
actual.FsConfig.S3Config.AccessSecret = ""
|
||||
expected.FsConfig.S3Config.Endpoint = "http://127.0.0.1:9000/"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 endpoint does not match")
|
||||
}
|
||||
expected.FsConfig.S3Config.Endpoint = ""
|
||||
expected.FsConfig.S3Config.StorageClass = "Standard"
|
||||
err = compareUserFsConfig(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("S3 storage class does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCallsWithBadURL(t *testing.T) {
|
||||
oldBaseURL := httpBaseURL
|
||||
SetBaseURL(invalidURL)
|
||||
|
@ -315,3 +391,18 @@ func TestRenderInvalidTemplate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaScanInvalidFs(t *testing.T) {
|
||||
user := dataprovider.User{
|
||||
Username: "test",
|
||||
HomeDir: os.TempDir(),
|
||||
FsConfig: dataprovider.Filesystem{
|
||||
Provider: 1,
|
||||
},
|
||||
}
|
||||
sftpd.AddQuotaScan(user.Username)
|
||||
err := doQuotaScan(user)
|
||||
if err == nil {
|
||||
t.Error("quota scan with bad fs must fail")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -710,6 +710,50 @@ components:
|
|||
nullable: true
|
||||
description: clients connecting from these IP/Mask are not allowed. Denied rules are evaluated before allowed ones
|
||||
example: [ "172.16.0.0/16" ]
|
||||
description: Additional restrictions
|
||||
S3Config:
|
||||
type: object
|
||||
properties:
|
||||
bucket:
|
||||
type: string
|
||||
minLength: 1
|
||||
region:
|
||||
type: string
|
||||
minLength: 1
|
||||
access_key:
|
||||
type: string
|
||||
minLength: 1
|
||||
access_secret:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: the access secret is stored encrypted (AES-256-GCM)
|
||||
endpoint:
|
||||
type: string
|
||||
description: optional endpoint
|
||||
storage_class:
|
||||
type: string
|
||||
required:
|
||||
- bucket
|
||||
- region
|
||||
- access_key
|
||||
- access_secret
|
||||
nullable: true
|
||||
description: S3 Compatible Object Storage configuration details
|
||||
FilesystemConfig:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
description: >
|
||||
Providers:
|
||||
* `0` - local filesystem
|
||||
* `1` - S3 Compatible Object Storage
|
||||
s3config:
|
||||
$ref: '#/components/schemas/S3Config'
|
||||
description: Storage filesystem details
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -799,8 +843,8 @@ components:
|
|||
description: Last user login as unix timestamp in milliseconds
|
||||
filters:
|
||||
$ref: '#/components/schemas/UserFilters'
|
||||
nullable: true
|
||||
description: Additional restrictions
|
||||
filesystem:
|
||||
$ref: '#/components/schemas/FilesystemConfig'
|
||||
Transfer:
|
||||
type: object
|
||||
properties:
|
||||
|
|
19
httpd/web.go
19
httpd/web.go
|
@ -224,6 +224,24 @@ func getFiltersFromUserPostFields(r *http.Request) dataprovider.UserFilters {
|
|||
return filters
|
||||
}
|
||||
|
||||
func getFsConfigFromUserPostFields(r *http.Request) dataprovider.Filesystem {
|
||||
var fs dataprovider.Filesystem
|
||||
provider, err := strconv.Atoi(r.Form.Get("fs_provider"))
|
||||
if err != nil {
|
||||
provider = 0
|
||||
}
|
||||
fs.Provider = provider
|
||||
if fs.Provider == 1 {
|
||||
fs.S3Config.Bucket = r.Form.Get("s3_bucket")
|
||||
fs.S3Config.Region = r.Form.Get("s3_region")
|
||||
fs.S3Config.AccessKey = r.Form.Get("s3_access_key")
|
||||
fs.S3Config.AccessSecret = r.Form.Get("s3_access_secret")
|
||||
fs.S3Config.Endpoint = r.Form.Get("s3_endpoint")
|
||||
fs.S3Config.StorageClass = r.Form.Get("s3_storage_class")
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||
var user dataprovider.User
|
||||
err := r.ParseForm()
|
||||
|
@ -289,6 +307,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
|||
Status: status,
|
||||
ExpirationDate: expirationDateMillis,
|
||||
Filters: getFiltersFromUserPostFields(r),
|
||||
FsConfig: getFsConfigFromUserPostFields(r),
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32"
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -53,6 +53,17 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 60,
|
||||
"expiration_date": 1546297200000,
|
||||
"filesystem": {
|
||||
"provider": 1,
|
||||
"s3config": {
|
||||
"access_key": "accesskey",
|
||||
"access_secret": "$aes$6c088ba12b0b261247c8cf331c46d9260b8e58002957d89ad1c0495e3af665cd0227",
|
||||
"bucket": "test",
|
||||
"endpoint": "http://127.0.0.1:9000",
|
||||
"region": "eu-west-1",
|
||||
"storage_class": "Standard"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"allowed_ip": [
|
||||
"192.168.1.1/32"
|
||||
|
@ -99,7 +110,7 @@ Output:
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24"
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --fs local
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -126,6 +137,10 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 80,
|
||||
"expiration_date": 0,
|
||||
"filesystem": {
|
||||
"provider": 0,
|
||||
"s3config": {}
|
||||
},
|
||||
"filters": {
|
||||
"allowed_ip": [],
|
||||
"denied_ip": [
|
||||
|
@ -174,6 +189,10 @@ Output:
|
|||
{
|
||||
"download_bandwidth": 80,
|
||||
"expiration_date": 0,
|
||||
"filesystem": {
|
||||
"provider": 0,
|
||||
"s3config": {}
|
||||
},
|
||||
"filters": {
|
||||
"allowed_ip": [],
|
||||
"denied_ip": [
|
||||
|
|
|
@ -70,9 +70,10 @@ class SFTPGoApiRequests:
|
|||
else:
|
||||
print(r.text)
|
||||
|
||||
def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0,
|
||||
gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0,
|
||||
download_bandwidth=0, status=1, expiration_date=0, allowed_ip=[], denied_ip=[]):
|
||||
def buildUserObject(self, user_id=0, username="", password="", public_keys=[], home_dir="", uid=0, gid=0,
|
||||
max_sessions=0, quota_size=0, quota_files=0, permissions={}, upload_bandwidth=0, download_bandwidth=0,
|
||||
status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
|
||||
s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
|
||||
user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
|
||||
"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
|
||||
"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth,
|
||||
|
@ -90,6 +91,8 @@ class SFTPGoApiRequests:
|
|||
user.update({"permissions":permissions})
|
||||
if allowed_ip or denied_ip:
|
||||
user.update({"filters":self.buildFilters(allowed_ip, denied_ip)})
|
||||
user.update({"filesystem":self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key,
|
||||
s3_access_secret, s3_endpoint, s3_storage_class)})
|
||||
return user
|
||||
|
||||
def buildPermissions(self, root_perms, subdirs_perms):
|
||||
|
@ -113,16 +116,25 @@ class SFTPGoApiRequests:
|
|||
filters = {}
|
||||
if allowed_ip:
|
||||
if len(allowed_ip) == 1 and not allowed_ip[0]:
|
||||
filters.update({"allowed_ip":[]})
|
||||
filters.update({'allowed_ip':[]})
|
||||
else:
|
||||
filters.update({"allowed_ip":allowed_ip})
|
||||
filters.update({'allowed_ip':allowed_ip})
|
||||
if denied_ip:
|
||||
if len(denied_ip) == 1 and not denied_ip[0]:
|
||||
filters.update({"denied_ip":[]})
|
||||
filters.update({'denied_ip':[]})
|
||||
else:
|
||||
filters.update({"denied_ip":denied_ip})
|
||||
filters.update({'denied_ip':denied_ip})
|
||||
return filters
|
||||
|
||||
def buildFsConfig(self, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint,
|
||||
s3_storage_class):
|
||||
fs_config = {'provider':0}
|
||||
if fs_provider == 'S3':
|
||||
s3config = {'bucket':s3_bucket, 'region':s3_region, 'access_key':s3_access_key, 'access_secret':
|
||||
s3_access_secret, 'endpoint':s3_endpoint, 'storage_class':s3_storage_class}
|
||||
fs_config.update({'provider':1, 's3config':s3config})
|
||||
return fs_config
|
||||
|
||||
def getUsers(self, limit=100, offset=0, order="ASC", username=""):
|
||||
r = requests.get(self.userPath, params={"limit":limit, "offset":offset, "order":order,
|
||||
"username":username}, auth=self.auth, verify=self.verify)
|
||||
|
@ -132,22 +144,25 @@ class SFTPGoApiRequests:
|
|||
r = requests.get(urlparse.urljoin(self.userPath, "user/" + str(user_id)), auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
|
||||
quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
|
||||
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[]):
|
||||
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0, quota_size=0,
|
||||
quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1, expiration_date=0,
|
||||
subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
|
||||
s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
|
||||
u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date, allowed_ip, denied_ip)
|
||||
status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region,
|
||||
s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class)
|
||||
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0,
|
||||
max_sessions=0, quota_size=0, quota_files=0, perms=[], upload_bandwidth=0,
|
||||
download_bandwidth=0, status=1, expiration_date=0, subdirs_permissions=[],
|
||||
allowed_ip=[], denied_ip=[]):
|
||||
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
|
||||
quota_size=0, quota_files=0, perms=[], upload_bandwidth=0, download_bandwidth=0, status=1,
|
||||
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
|
||||
s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class=''):
|
||||
u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date, allowed_ip, denied_ip)
|
||||
status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
|
||||
s3_access_secret, s3_endpoint, s3_storage_class)
|
||||
r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
|
@ -238,7 +253,7 @@ class ConvertUsers:
|
|||
self.convertFromProFTPD()
|
||||
self.saveUsers()
|
||||
|
||||
def isUserValid(self, username, uid, gid):
|
||||
def isUserValid(self, username, uid):
|
||||
if self.usernames and not username in self.usernames:
|
||||
return False
|
||||
if self.min_uid >= 0 and uid < self.min_uid:
|
||||
|
@ -257,7 +272,7 @@ class ConvertUsers:
|
|||
home_dir = user.pw_dir
|
||||
status = 1
|
||||
expiration_date = 0
|
||||
if not self.isUserValid(username, uid, gid):
|
||||
if not self.isUserValid(username, uid):
|
||||
continue
|
||||
if self.force_uid >= 0:
|
||||
uid = self.force_uid
|
||||
|
@ -375,7 +390,7 @@ def addCommonUserArguments(parser):
|
|||
parser.add_argument('username', type=str)
|
||||
parser.add_argument('-P', '--password', type=str, default=None, help='Default: %(default)s')
|
||||
parser.add_argument('-K', '--public-keys', type=str, nargs='+', default=[], help='Default: %(default)s')
|
||||
parser.add_argument('-H', '--home-dir', type=str, default="", help='Default: %(default)s')
|
||||
parser.add_argument('-H', '--home-dir', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--uid', type=int, default=0, help='Default: %(default)s')
|
||||
parser.add_argument('--gid', type=int, default=0, help='Default: %(default)s')
|
||||
parser.add_argument('-C', '--max-sessions', type=int, default=0,
|
||||
|
@ -401,6 +416,14 @@ def addCommonUserArguments(parser):
|
|||
help='Allowed IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
|
||||
parser.add_argument('-N', '--denied-ip', type=str, nargs='+', default=[],
|
||||
help='Denied IP/Mask in CIDR notation. For example "192.168.2.0/24" or "2001:db8::/32". Default: %(default)s')
|
||||
parser.add_argument('--fs', type=str, default='local', choices=['local', 'S3'],
|
||||
help='Filesystem provider. Default: %(default)s')
|
||||
parser.add_argument('--s3-bucket', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--s3-region', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--s3-access-key', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--s3-access-secret', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--s3-endpoint', type=str, default='', help='Default: %(default)s')
|
||||
parser.add_argument('--s3-storage-class', type=str, default='', help='Default: %(default)s')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -503,12 +526,14 @@ if __name__ == '__main__':
|
|||
api.addUser(args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions,
|
||||
args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth,
|
||||
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date), args.subdirs_permissions, args.allowed_ip,
|
||||
args.denied_ip)
|
||||
args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
|
||||
args.s3_endpoint, args.s3_storage_class)
|
||||
elif args.command == 'update-user':
|
||||
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
|
||||
args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
|
||||
args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date),
|
||||
args.subdirs_permissions, args.allowed_ip, args.denied_ip)
|
||||
args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region,
|
||||
args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class)
|
||||
elif args.command == 'delete-user':
|
||||
api.deleteUser(args.id)
|
||||
elif args.command == 'get-users':
|
||||
|
|
255
sftpd/handler.go
255
sftpd/handler.go
|
@ -1,19 +1,14 @@
|
|||
package sftpd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/rs/xid"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
|
@ -40,6 +35,7 @@ type Connection struct {
|
|||
netConn net.Conn
|
||||
channel ssh.Channel
|
||||
command string
|
||||
fs vfs.Fs
|
||||
}
|
||||
|
||||
// Log outputs a log entry to the configured logger
|
||||
|
@ -55,26 +51,29 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
|
|||
return nil, sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
||||
p, err := c.buildPath(request.Filepath)
|
||||
p, err := c.fs.ResolvePath(request.Filepath, c.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
fi, err := os.Stat(p)
|
||||
fi, err := c.fs.Stat(p)
|
||||
if err != nil {
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
file, err := os.Open(p)
|
||||
file, r, cancelFn, err := c.fs.Open(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "could not open file %#v for reading: %v", p, err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
c.Log(logger.LevelDebug, logSender, "fileread requested for path: %#v", p)
|
||||
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
readerAt: r,
|
||||
writerAt: nil,
|
||||
cancelFn: cancelFn,
|
||||
path: p,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
|
@ -98,18 +97,18 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) {
|
|||
// Filewrite handles the write actions for a file on the system.
|
||||
func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||
updateConnectionActivity(c.ID)
|
||||
p, err := c.buildPath(request.Filepath)
|
||||
p, err := c.fs.ResolvePath(request.Filepath, c.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
filePath := p
|
||||
if isAtomicUploadEnabled() {
|
||||
filePath = getUploadTempFilePath(p)
|
||||
if isAtomicUploadEnabled() && c.fs.IsAtomicUploadSupported() {
|
||||
filePath = c.fs.GetAtomicUploadPath(p)
|
||||
}
|
||||
|
||||
stat, statErr := os.Stat(p)
|
||||
if os.IsNotExist(statErr) {
|
||||
stat, statErr := c.fs.Stat(p)
|
||||
if c.fs.IsNotExist(statErr) {
|
||||
if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(request.Filepath)) {
|
||||
return nil, sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
@ -118,7 +117,7 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
|||
|
||||
if statErr != nil {
|
||||
c.Log(logger.LevelError, logSender, "error performing file stat %#v: %v", p, statErr)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, statErr)
|
||||
}
|
||||
|
||||
// This happen if we upload a file that has the same name of an existing directory
|
||||
|
@ -139,9 +138,9 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
|||
func (c Connection) Filecmd(request *sftp.Request) error {
|
||||
updateConnectionActivity(c.ID)
|
||||
|
||||
p, err := c.buildPath(request.Filepath)
|
||||
p, err := c.fs.ResolvePath(request.Filepath, c.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
target, err := c.getSFTPCmdTargetPath(request.Target)
|
||||
if err != nil {
|
||||
|
@ -186,7 +185,7 @@ func (c Connection) Filecmd(request *sftp.Request) error {
|
|||
}
|
||||
|
||||
// we return if we remove a file or a dir so source path or target path always exists here
|
||||
utils.SetPathPermissions(fileLocation, c.User.GetUID(), c.User.GetGID())
|
||||
vfs.SetPathPermissions(c.fs, fileLocation, c.User.GetUID(), c.User.GetGID())
|
||||
|
||||
return sftp.ErrSSHFxOk
|
||||
}
|
||||
|
@ -195,9 +194,9 @@ func (c Connection) Filecmd(request *sftp.Request) error {
|
|||
// a directory as well as perform file/folder stat calls.
|
||||
func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
||||
updateConnectionActivity(c.ID)
|
||||
p, err := c.buildPath(request.Filepath)
|
||||
p, err := c.fs.ResolvePath(request.Filepath, c.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
switch request.Method {
|
||||
|
@ -208,10 +207,10 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
|||
|
||||
c.Log(logger.LevelDebug, logSender, "requested list file for dir: %#v", p)
|
||||
|
||||
files, err := ioutil.ReadDir(p)
|
||||
files, err := c.fs.ReadDir(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error listing directory: %#v", err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
return listerAt(files), nil
|
||||
|
@ -221,10 +220,10 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
|||
}
|
||||
|
||||
c.Log(logger.LevelDebug, logSender, "requested stat for path: %#v", p)
|
||||
s, err := os.Stat(p)
|
||||
s, err := c.fs.Stat(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error running stat on path: %#v", err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
return listerAt([]os.FileInfo{s}), nil
|
||||
|
@ -239,9 +238,9 @@ func (c Connection) getSFTPCmdTargetPath(requestTarget string) (string, error) {
|
|||
// location for the server. If it is not, return an error
|
||||
if len(requestTarget) > 0 {
|
||||
var err error
|
||||
target, err = c.buildPath(requestTarget)
|
||||
target, err = c.fs.ResolvePath(requestTarget, c.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return target, getSFTPErrorFromOSError(err)
|
||||
return target, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
|
@ -252,7 +251,7 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
|
|||
return nil
|
||||
}
|
||||
pathForPerms := request.Filepath
|
||||
if fi, err := os.Lstat(filePath); err == nil {
|
||||
if fi, err := c.fs.Lstat(filePath); err == nil {
|
||||
if fi.IsDir() {
|
||||
pathForPerms = path.Dir(request.Filepath)
|
||||
}
|
||||
|
@ -263,9 +262,9 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
|
|||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
fileMode := request.Attributes().FileMode()
|
||||
if err := os.Chmod(filePath, fileMode); err != nil {
|
||||
if err := c.fs.Chmod(filePath, fileMode); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to chmod path %#v, mode: %v, err: %v", filePath, fileMode.String(), err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
logger.CommandLog(chmodLogSender, filePath, "", c.User.Username, fileMode.String(), c.ID, c.protocol, -1, -1, "", "", "")
|
||||
return nil
|
||||
|
@ -275,9 +274,9 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
|
|||
}
|
||||
uid := int(request.Attributes().UID)
|
||||
gid := int(request.Attributes().GID)
|
||||
if err := os.Chown(filePath, uid, gid); err != nil {
|
||||
if err := c.fs.Chown(filePath, uid, gid); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to chown path %#v, uid: %v, gid: %v, err: %v", filePath, uid, gid, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
logger.CommandLog(chownLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, uid, gid, "", "", "")
|
||||
return nil
|
||||
|
@ -290,10 +289,10 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
|
|||
modificationTime := time.Unix(int64(request.Attributes().Mtime), 0)
|
||||
accessTimeString := accessTime.Format(dateFormat)
|
||||
modificationTimeString := modificationTime.Format(dateFormat)
|
||||
if err := os.Chtimes(filePath, accessTime, modificationTime); err != nil {
|
||||
if err := c.fs.Chtimes(filePath, accessTime, modificationTime); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to chtimes for path %#v, access time: %v, modification time: %v, err: %v",
|
||||
filePath, accessTime, modificationTime, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
logger.CommandLog(chtimesLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, accessTimeString,
|
||||
modificationTimeString, "")
|
||||
|
@ -303,16 +302,16 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er
|
|||
}
|
||||
|
||||
func (c Connection) handleSFTPRename(sourcePath string, targetPath string, request *sftp.Request) error {
|
||||
if c.User.GetRelativePath(sourcePath) == "/" {
|
||||
if c.fs.GetRelativePath(sourcePath, c.User.GetHomeDir()) == "/" {
|
||||
c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if err := os.Rename(sourcePath, targetPath); err != nil {
|
||||
if err := c.fs.Rename(sourcePath, targetPath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to rename file, source: %#v target: %#v: %v", sourcePath, targetPath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
logger.CommandLog(renameLogSender, sourcePath, targetPath, c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
|
||||
go executeAction(operationRename, c.User.Username, sourcePath, targetPath, "", 0)
|
||||
|
@ -320,7 +319,7 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string, reque
|
|||
}
|
||||
|
||||
func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error {
|
||||
if c.User.GetRelativePath(dirPath) == "/" {
|
||||
if c.fs.GetRelativePath(dirPath, c.User.GetHomeDir()) == "/" {
|
||||
c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
@ -330,18 +329,18 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error
|
|||
|
||||
var fi os.FileInfo
|
||||
var err error
|
||||
if fi, err = os.Lstat(dirPath); err != nil {
|
||||
if fi, err = c.fs.Lstat(dirPath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to remove a dir %#v: stat error: %v", dirPath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
if !fi.IsDir() || fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
c.Log(logger.LevelDebug, logSender, "cannot remove %#v is not a directory", dirPath)
|
||||
return sftp.ErrSSHFxFailure
|
||||
}
|
||||
|
||||
if err = os.Remove(dirPath); err != nil {
|
||||
if err = c.fs.Remove(dirPath, true); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to remove directory %#v: %v", dirPath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
logger.CommandLog(rmdirLogSender, dirPath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
|
||||
|
@ -349,16 +348,16 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error
|
|||
}
|
||||
|
||||
func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, request *sftp.Request) error {
|
||||
if c.User.GetRelativePath(sourcePath) == "/" {
|
||||
if c.fs.GetRelativePath(sourcePath, c.User.GetHomeDir()) == "/" {
|
||||
c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if !c.User.HasPerm(dataprovider.PermCreateSymlinks, path.Dir(request.Target)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if err := os.Symlink(sourcePath, targetPath); err != nil {
|
||||
if err := c.fs.Symlink(sourcePath, targetPath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to create symlink %#v -> %#v: %v", sourcePath, targetPath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
logger.CommandLog(symlinkLogSender, sourcePath, targetPath, c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
|
||||
|
@ -369,11 +368,11 @@ func (c Connection) handleSFTPMkdir(dirPath string, request *sftp.Request) error
|
|||
if !c.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(request.Filepath)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if err := os.Mkdir(dirPath, 0777); err != nil {
|
||||
if err := c.fs.Mkdir(dirPath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error creating missing dir: %#v error: %v", dirPath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
utils.SetPathPermissions(dirPath, c.User.GetUID(), c.User.GetGID())
|
||||
vfs.SetPathPermissions(c.fs, dirPath, c.User.GetUID(), c.User.GetGID())
|
||||
|
||||
logger.CommandLog(mkdirLogSender, dirPath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
|
||||
return nil
|
||||
|
@ -387,18 +386,18 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err
|
|||
var size int64
|
||||
var fi os.FileInfo
|
||||
var err error
|
||||
if fi, err = os.Lstat(filePath); err != nil {
|
||||
if fi, err = c.fs.Lstat(filePath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to remove a file %#v: stat error: %v", filePath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
if fi.IsDir() && fi.Mode()&os.ModeSymlink != os.ModeSymlink {
|
||||
c.Log(logger.LevelDebug, logSender, "cannot remove %#v is not a file/symlink", filePath)
|
||||
return sftp.ErrSSHFxFailure
|
||||
}
|
||||
size = fi.Size()
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
if err := c.fs.Remove(filePath, false); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %v", filePath, err)
|
||||
return getSFTPErrorFromOSError(err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
|
||||
|
@ -416,16 +415,19 @@ func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string) (io.
|
|||
return nil, sftp.ErrSSHFxFailure
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
file, w, cancelFn, err := c.fs.Create(filePath, 0)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error creating file %#v: %v", requestPath, err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
utils.SetPathPermissions(filePath, c.User.GetUID(), c.User.GetGID())
|
||||
vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
|
||||
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
writerAt: w,
|
||||
readerAt: nil,
|
||||
cancelFn: cancelFn,
|
||||
path: requestPath,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
|
@ -456,19 +458,25 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re
|
|||
minWriteOffset := int64(0)
|
||||
osFlags := getOSOpenFlags(pflags)
|
||||
|
||||
if isAtomicUploadEnabled() {
|
||||
err = os.Rename(requestPath, filePath)
|
||||
if pflags.Append && osFlags&os.O_TRUNC == 0 && !c.fs.IsUploadResumeSupported() {
|
||||
c.Log(logger.LevelInfo, logSender, "upload resume requested for path: %#v but not supported in fs implementation",
|
||||
requestPath)
|
||||
return nil, sftp.ErrSSHFxOpUnsupported
|
||||
}
|
||||
|
||||
if isAtomicUploadEnabled() && c.fs.IsAtomicUploadSupported() {
|
||||
err = c.fs.Rename(requestPath, filePath)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error renaming existing file for atomic upload, source: %#v, dest: %#v, err: %v",
|
||||
requestPath, filePath, err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
}
|
||||
// we use 0666 so the umask is applied
|
||||
file, err := os.OpenFile(filePath, osFlags, 0666)
|
||||
|
||||
file, w, cancelFn, err := c.fs.Create(filePath, osFlags)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error opening existing file, flags: %v, source: %#v, err: %v", pflags, filePath, err)
|
||||
return nil, getSFTPErrorFromOSError(err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
if pflags.Append && osFlags&os.O_TRUNC == 0 {
|
||||
|
@ -478,10 +486,13 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re
|
|||
dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false)
|
||||
}
|
||||
|
||||
utils.SetPathPermissions(filePath, c.User.GetUID(), c.User.GetGID())
|
||||
vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID())
|
||||
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
writerAt: w,
|
||||
readerAt: nil,
|
||||
cancelFn: cancelFn,
|
||||
path: requestPath,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
|
@ -522,103 +533,6 @@ func (c Connection) hasSpace(checkFiles bool) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Normalizes a file/directory we get from the SFTP request to ensure the user is not able to escape
|
||||
// from their data directory. After normalization if the file/directory is still within their home
|
||||
// path it is returned. If they managed to "escape" an error will be returned.
|
||||
func (c Connection) buildPath(rawPath string) (string, error) {
|
||||
r := filepath.Clean(filepath.Join(c.User.HomeDir, rawPath))
|
||||
p, err := filepath.EvalSymlinks(r)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
} else if os.IsNotExist(err) {
|
||||
// The requested path doesn't exist, so at this point we need to iterate up the
|
||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||
_, err = c.findFirstExistingDir(r)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error resolving not existent path: %#v", err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
err = c.isSubDir(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, c.User.HomeDir, err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// iterate up the path chain until we hit a directory that does exist and can be validated.
|
||||
// all nonexistent directories will be returned
|
||||
func (c Connection) findNonexistentDirs(path string) ([]string, error) {
|
||||
results := []string{}
|
||||
cleanPath := filepath.Clean(path)
|
||||
parent := filepath.Dir(cleanPath)
|
||||
_, err := os.Stat(parent)
|
||||
|
||||
for os.IsNotExist(err) {
|
||||
results = append(results, parent)
|
||||
parent = filepath.Dir(parent)
|
||||
_, err = os.Stat(parent)
|
||||
}
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
p, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
err = c.isSubDir(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "Error finding non existing dir: %v", err)
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
|
||||
// iterate up the path chain until we hit a directory that does exist and can be validated.
|
||||
func (c Connection) findFirstExistingDir(path string) (string, error) {
|
||||
results, err := c.findNonexistentDirs(path)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "unable to find non existent dirs: %v", err)
|
||||
return "", err
|
||||
}
|
||||
var parent string
|
||||
if len(results) > 0 {
|
||||
lastMissingDir := results[len(results)-1]
|
||||
parent = filepath.Dir(lastMissingDir)
|
||||
} else {
|
||||
parent = c.User.GetHomeDir()
|
||||
}
|
||||
p, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileInfo, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !fileInfo.IsDir() {
|
||||
return "", fmt.Errorf("resolved path is not a dir: %#v", p)
|
||||
}
|
||||
err = c.isSubDir(p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// checks if sub is a subpath of the user home dir.
|
||||
// EvalSymlink must be used on sub before calling this method
|
||||
func (c Connection) isSubDir(sub string) error {
|
||||
// home dir must exist and it is already a validated absolute path
|
||||
parent, err := filepath.EvalSymlinks(c.User.HomeDir)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "invalid home dir %#v: %v", c.User.HomeDir, err)
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(sub, parent) {
|
||||
c.Log(logger.LevelWarn, logSender, "path %#v is not inside: %#v ", sub, parent)
|
||||
return fmt.Errorf("path %#v is not inside: %#v", sub, parent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Connection) close() error {
|
||||
if c.channel != nil {
|
||||
err := c.channel.Close()
|
||||
|
@ -649,20 +563,3 @@ func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int) {
|
|||
}
|
||||
return osFlags
|
||||
}
|
||||
|
||||
func getUploadTempFilePath(path string) string {
|
||||
dir := filepath.Dir(path)
|
||||
guid := xid.New().String()
|
||||
return filepath.Join(dir, ".sftpgo-upload."+guid+"."+filepath.Base(path))
|
||||
}
|
||||
|
||||
func getSFTPErrorFromOSError(err error) error {
|
||||
if os.IsNotExist(err) {
|
||||
return sftp.ErrSSHFxNoSuchFile
|
||||
} else if os.IsPermission(err) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
} else if err != nil {
|
||||
return sftp.ErrSSHFxFailure
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
@ -16,6 +17,8 @@ import (
|
|||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"github.com/eikenb/pipeat"
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
|
@ -60,6 +63,61 @@ func (c *MockChannel) Stderr() io.ReadWriter {
|
|||
return c.StdErrBuffer
|
||||
}
|
||||
|
||||
// MockOsFs mockable OsFs
|
||||
type MockOsFs struct {
|
||||
vfs.OsFs
|
||||
err error
|
||||
statErr error
|
||||
isAtomicUploadSupported bool
|
||||
}
|
||||
|
||||
// Name returns the name for the Fs implementation
|
||||
func (fs MockOsFs) Name() string {
|
||||
return "mockOsFs"
|
||||
}
|
||||
|
||||
// IsUploadResumeSupported returns true if upload resume is supported
|
||||
func (MockOsFs) IsUploadResumeSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAtomicUploadSupported returns true if atomic upload is supported
|
||||
func (fs MockOsFs) IsAtomicUploadSupported() bool {
|
||||
return fs.isAtomicUploadSupported
|
||||
}
|
||||
|
||||
// Stat returns a FileInfo describing the named file
|
||||
func (fs MockOsFs) Stat(name string) (os.FileInfo, error) {
|
||||
if fs.statErr != nil {
|
||||
return nil, fs.statErr
|
||||
}
|
||||
return os.Stat(name)
|
||||
}
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
func (fs MockOsFs) Remove(name string, isDir bool) error {
|
||||
if fs.err != nil {
|
||||
return fs.err
|
||||
}
|
||||
return os.Remove(name)
|
||||
}
|
||||
|
||||
// Rename renames (moves) source to target
|
||||
func (fs MockOsFs) Rename(source, target string) error {
|
||||
if fs.err != nil {
|
||||
return fs.err
|
||||
}
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
|
||||
func newMockOsFs(err, statErr error, atomicUpload bool) vfs.Fs {
|
||||
return &MockOsFs{
|
||||
err: err,
|
||||
statErr: statErr,
|
||||
isAtomicUploadSupported: atomicUpload,
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongActions(t *testing.T) {
|
||||
actionsCopy := actions
|
||||
badCommand := "/bad/command"
|
||||
|
@ -218,13 +276,134 @@ func TestReadWriteErrors(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Error("upoload must fail the expected size does not match")
|
||||
}
|
||||
r, _, _ := pipeat.Pipe()
|
||||
transfer = Transfer{
|
||||
readerAt: r,
|
||||
writerAt: nil,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
user: dataprovider.User{
|
||||
Username: "testuser",
|
||||
},
|
||||
connectionID: "",
|
||||
transferType: transferDownload,
|
||||
lastActivity: time.Now(),
|
||||
isNewFile: false,
|
||||
protocol: protocolSFTP,
|
||||
transferError: nil,
|
||||
isFinished: false,
|
||||
lock: new(sync.Mutex),
|
||||
}
|
||||
transfer.closeIO()
|
||||
_, err = transfer.ReadAt(buf, 0)
|
||||
if err == nil {
|
||||
t.Error("reading from a closed pipe must fail")
|
||||
}
|
||||
r, w, _ := pipeat.Pipe()
|
||||
transfer = Transfer{
|
||||
readerAt: nil,
|
||||
writerAt: w,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
user: dataprovider.User{
|
||||
Username: "testuser",
|
||||
},
|
||||
connectionID: "",
|
||||
transferType: transferDownload,
|
||||
lastActivity: time.Now(),
|
||||
isNewFile: false,
|
||||
protocol: protocolSFTP,
|
||||
transferError: nil,
|
||||
isFinished: false,
|
||||
lock: new(sync.Mutex),
|
||||
}
|
||||
r.Close()
|
||||
transfer.closeIO()
|
||||
_, err = transfer.WriteAt([]byte("test"), 0)
|
||||
if err == nil {
|
||||
t.Error("writing to closed pipe must fail")
|
||||
}
|
||||
os.Remove(testfile)
|
||||
}
|
||||
|
||||
func TestTransferCancelFn(t *testing.T) {
|
||||
testfile := "testfile"
|
||||
file, _ := os.Create(testfile)
|
||||
isCancelled := false
|
||||
cancelFn := func() {
|
||||
isCancelled = true
|
||||
}
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
cancelFn: cancelFn,
|
||||
path: file.Name(),
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
bytesReceived: 0,
|
||||
user: dataprovider.User{
|
||||
Username: "testuser",
|
||||
},
|
||||
connectionID: "",
|
||||
transferType: transferDownload,
|
||||
lastActivity: time.Now(),
|
||||
isNewFile: false,
|
||||
protocol: protocolSFTP,
|
||||
transferError: nil,
|
||||
isFinished: false,
|
||||
minWriteOffset: 0,
|
||||
expectedSize: 10,
|
||||
lock: new(sync.Mutex),
|
||||
}
|
||||
transfer.TransferError(errors.New("fake error, this will trigger cancelFn"))
|
||||
transfer.Close()
|
||||
if !isCancelled {
|
||||
t.Error("cancelFn not called")
|
||||
}
|
||||
os.Remove(testfile)
|
||||
}
|
||||
|
||||
func TestMockFsErrors(t *testing.T) {
|
||||
errFake := errors.New("fake error")
|
||||
fs := newMockOsFs(errFake, errFake, false)
|
||||
u := dataprovider.User{}
|
||||
u.Username = "test"
|
||||
u.Permissions = make(map[string][]string)
|
||||
u.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
u.HomeDir = os.TempDir()
|
||||
c := Connection{
|
||||
fs: fs,
|
||||
User: u,
|
||||
}
|
||||
testfile := filepath.Join(u.HomeDir, "testfile")
|
||||
request := sftp.NewRequest("Remove", testfile)
|
||||
ioutil.WriteFile(testfile, []byte("test"), 0666)
|
||||
err := c.handleSFTPRemove(testfile, request)
|
||||
if err != sftp.ErrSSHFxFailure {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
_, err = c.Filewrite(request)
|
||||
if err != sftp.ErrSSHFxFailure {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
var flags sftp.FileOpenFlags
|
||||
flags.Write = true
|
||||
flags.Trunc = false
|
||||
flags.Append = true
|
||||
_, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0)
|
||||
if err != sftp.ErrSSHFxOpUnsupported {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
os.Remove(testfile)
|
||||
}
|
||||
|
||||
func TestUploadFiles(t *testing.T) {
|
||||
oldUploadMode := uploadMode
|
||||
uploadMode = uploadModeAtomic
|
||||
c := Connection{}
|
||||
c := Connection{
|
||||
fs: vfs.NewOsFs("123"),
|
||||
}
|
||||
var flags sftp.FileOpenFlags
|
||||
flags.Write = true
|
||||
flags.Trunc = true
|
||||
|
@ -255,10 +434,13 @@ func TestWithInvalidHome(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Errorf("login a user with an invalid home_dir must fail")
|
||||
}
|
||||
fs, _ := u.GetFilesystem("123")
|
||||
c := Connection{
|
||||
User: u,
|
||||
fs: fs,
|
||||
}
|
||||
err = c.isSubDir("dir_rel_path")
|
||||
u.HomeDir = os.TempDir()
|
||||
_, err = c.fs.ResolvePath("../upper_path", u.GetHomeDir())
|
||||
if err == nil {
|
||||
t.Errorf("tested path is not a home subdir")
|
||||
}
|
||||
|
@ -266,12 +448,18 @@ func TestWithInvalidHome(t *testing.T) {
|
|||
|
||||
func TestSFTPCmdTargetPath(t *testing.T) {
|
||||
u := dataprovider.User{}
|
||||
u.HomeDir = "home_rel_path"
|
||||
if runtime.GOOS == "windows" {
|
||||
u.HomeDir = "C:\\invalid_home"
|
||||
} else {
|
||||
u.HomeDir = "/invalid_home"
|
||||
}
|
||||
u.Username = "test"
|
||||
u.Permissions = make(map[string][]string)
|
||||
u.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
fs, _ := u.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
User: u,
|
||||
fs: fs,
|
||||
}
|
||||
_, err := connection.getSFTPCmdTargetPath("invalid_path")
|
||||
if err != sftp.ErrSSHFxNoSuchFile {
|
||||
|
@ -281,16 +469,17 @@ func TestSFTPCmdTargetPath(t *testing.T) {
|
|||
|
||||
func TestGetSFTPErrorFromOSError(t *testing.T) {
|
||||
err := os.ErrNotExist
|
||||
err = getSFTPErrorFromOSError(err)
|
||||
fs := vfs.NewOsFs("")
|
||||
err = vfs.GetSFTPError(fs, err)
|
||||
if err != sftp.ErrSSHFxNoSuchFile {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
err = os.ErrPermission
|
||||
err = getSFTPErrorFromOSError(err)
|
||||
err = vfs.GetSFTPError(fs, err)
|
||||
if err != sftp.ErrSSHFxPermissionDenied {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
err = getSFTPErrorFromOSError(nil)
|
||||
err = vfs.GetSFTPError(fs, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
@ -418,10 +607,12 @@ func TestSSHCommandErrors(t *testing.T) {
|
|||
user := dataprovider.User{}
|
||||
user.Permissions = make(map[string][]string)
|
||||
user.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
fs, _ := user.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
cmd := sshCommand{
|
||||
command: "md5sum",
|
||||
|
@ -499,6 +690,45 @@ func TestSSHCommandErrors(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandsRemoteFs(t *testing.T) {
|
||||
buf := make([]byte, 65535)
|
||||
stdErrBuf := make([]byte, 65535)
|
||||
mockSSHChannel := MockChannel{
|
||||
Buffer: bytes.NewBuffer(buf),
|
||||
StdErrBuffer: bytes.NewBuffer(stdErrBuf),
|
||||
}
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
user := dataprovider.User{}
|
||||
user.FsConfig = dataprovider.Filesystem{
|
||||
Provider: 1}
|
||||
fs, _ := user.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
cmd := sshCommand{
|
||||
command: "md5sum",
|
||||
connection: connection,
|
||||
args: []string{},
|
||||
}
|
||||
err := cmd.handleHashCommands()
|
||||
if err == nil {
|
||||
t.Error("command must fail for a non local filesystem")
|
||||
}
|
||||
command, err := cmd.getSystemCommand()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
err = cmd.executeSystemCommand(command)
|
||||
if err == nil {
|
||||
t.Error("command must fail for a non local filesystem")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandQuotaScan(t *testing.T) {
|
||||
buf := make([]byte, 65535)
|
||||
stdErrBuf := make([]byte, 65535)
|
||||
|
@ -513,14 +743,17 @@ func TestSSHCommandQuotaScan(t *testing.T) {
|
|||
defer client.Close()
|
||||
permissions := make(map[string][]string)
|
||||
permissions["/"] = []string{dataprovider.PermAny}
|
||||
user := dataprovider.User{
|
||||
Permissions: permissions,
|
||||
QuotaFiles: 1,
|
||||
HomeDir: "invalid_path",
|
||||
}
|
||||
fs, _ := user.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
User: dataprovider.User{
|
||||
Permissions: permissions,
|
||||
QuotaFiles: 1,
|
||||
HomeDir: "invalid_path",
|
||||
},
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
cmd := sshCommand{
|
||||
command: "git-receive-pack",
|
||||
|
@ -536,11 +769,14 @@ func TestSSHCommandQuotaScan(t *testing.T) {
|
|||
func TestRsyncOptions(t *testing.T) {
|
||||
permissions := make(map[string][]string)
|
||||
permissions["/"] = []string{dataprovider.PermAny}
|
||||
user := dataprovider.User{
|
||||
Permissions: permissions,
|
||||
HomeDir: os.TempDir(),
|
||||
}
|
||||
fs, _ := user.GetFilesystem("123")
|
||||
conn := Connection{
|
||||
User: dataprovider.User{
|
||||
Permissions: permissions,
|
||||
HomeDir: os.TempDir(),
|
||||
},
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
sshCmd := sshCommand{
|
||||
command: "rsync",
|
||||
|
@ -556,11 +792,11 @@ func TestRsyncOptions(t *testing.T) {
|
|||
}
|
||||
permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs,
|
||||
dataprovider.PermListItems, dataprovider.PermOverwrite, dataprovider.PermDelete, dataprovider.PermRename}
|
||||
user.Permissions = permissions
|
||||
fs, _ = user.GetFilesystem("123")
|
||||
conn = Connection{
|
||||
User: dataprovider.User{
|
||||
Permissions: permissions,
|
||||
HomeDir: os.TempDir(),
|
||||
},
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
sshCmd = sshCommand{
|
||||
command: "rsync",
|
||||
|
@ -592,13 +828,16 @@ func TestSystemCommandErrors(t *testing.T) {
|
|||
defer client.Close()
|
||||
permissions := make(map[string][]string)
|
||||
permissions["/"] = []string{dataprovider.PermAny}
|
||||
user := dataprovider.User{
|
||||
Permissions: permissions,
|
||||
HomeDir: os.TempDir(),
|
||||
}
|
||||
fs, _ := user.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
User: dataprovider.User{
|
||||
Permissions: permissions,
|
||||
HomeDir: os.TempDir(),
|
||||
},
|
||||
User: user,
|
||||
fs: fs,
|
||||
}
|
||||
sshCmd := sshCommand{
|
||||
command: "ls",
|
||||
|
@ -934,6 +1173,55 @@ func TestSCPCommandHandleErrors(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSCPErrorsMockFs(t *testing.T) {
|
||||
errFake := errors.New("fake error")
|
||||
fs := newMockOsFs(errFake, errFake, false)
|
||||
u := dataprovider.User{}
|
||||
u.Username = "test"
|
||||
u.Permissions = make(map[string][]string)
|
||||
u.Permissions["/"] = []string{dataprovider.PermAny}
|
||||
u.HomeDir = os.TempDir()
|
||||
buf := make([]byte, 65535)
|
||||
stdErrBuf := make([]byte, 65535)
|
||||
mockSSHChannel := MockChannel{
|
||||
Buffer: bytes.NewBuffer(buf),
|
||||
StdErrBuffer: bytes.NewBuffer(stdErrBuf),
|
||||
}
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
fs: fs,
|
||||
User: u,
|
||||
}
|
||||
scpCommand := scpCommand{
|
||||
sshCommand: sshCommand{
|
||||
command: "scp",
|
||||
connection: connection,
|
||||
args: []string{"-r", "-t", "/tmp"},
|
||||
},
|
||||
}
|
||||
err := scpCommand.handleUpload("test", 0)
|
||||
if err != errFake {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
testfile := filepath.Join(u.HomeDir, "testfile")
|
||||
ioutil.WriteFile(testfile, []byte("test"), 0666)
|
||||
stat, _ := os.Stat(u.HomeDir)
|
||||
err = scpCommand.handleRecursiveDownload(u.HomeDir, stat)
|
||||
if err != errFake {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
scpCommand.sshCommand.connection.fs = newMockOsFs(errFake, nil, true)
|
||||
err = scpCommand.handleUpload(filepath.Base(testfile), 0)
|
||||
if err != errFake {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
os.Remove(testfile)
|
||||
}
|
||||
|
||||
func TestSCPRecursiveDownloadErrors(t *testing.T) {
|
||||
buf := make([]byte, 65535)
|
||||
stdErrBuf := make([]byte, 65535)
|
||||
|
@ -951,6 +1239,7 @@ func TestSCPRecursiveDownloadErrors(t *testing.T) {
|
|||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
fs: vfs.NewOsFs("123"),
|
||||
}
|
||||
scpCommand := scpCommand{
|
||||
sshCommand: sshCommand{
|
||||
|
@ -1033,9 +1322,11 @@ func TestSCPCreateDirs(t *testing.T) {
|
|||
ReadError: nil,
|
||||
WriteError: nil,
|
||||
}
|
||||
fs, _ := u.GetFilesystem("123")
|
||||
connection := Connection{
|
||||
User: u,
|
||||
channel: &mockSSHChannel,
|
||||
fs: fs,
|
||||
}
|
||||
scpCommand := scpCommand{
|
||||
sshCommand: sshCommand{
|
||||
|
|
50
sftpd/scp.go
50
sftpd/scp.go
|
@ -3,7 +3,6 @@ package sftpd
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
|
@ -16,6 +15,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -116,7 +116,7 @@ func (c *scpCommand) handleRecursiveUpload() error {
|
|||
|
||||
func (c *scpCommand) handleCreateDir(dirPath string) error {
|
||||
updateConnectionActivity(c.connection.ID)
|
||||
p, err := c.connection.buildPath(dirPath)
|
||||
p, err := c.connection.fs.ResolvePath(dirPath, c.connection.User.GetHomeDir())
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "error creating dir: %#v, invalid file path, err: %v", dirPath, err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
|
@ -189,17 +189,20 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i
|
|||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
file, w, cancelFn, err := c.connection.fs.Create(filePath, 0)
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelError, logSenderSCP, "error creating file %#v: %v", requestPath, err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
utils.SetPathPermissions(filePath, c.connection.User.GetUID(), c.connection.User.GetGID())
|
||||
vfs.SetPathPermissions(c.connection.fs, filePath, c.connection.User.GetUID(), c.connection.User.GetGID())
|
||||
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
readerAt: nil,
|
||||
writerAt: w,
|
||||
cancelFn: cancelFn,
|
||||
path: requestPath,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
|
@ -225,18 +228,18 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
|
|||
|
||||
updateConnectionActivity(c.connection.ID)
|
||||
|
||||
p, err := c.connection.buildPath(uploadFilePath)
|
||||
p, err := c.connection.fs.ResolvePath(uploadFilePath, c.connection.User.GetHomeDir())
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", uploadFilePath, err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
}
|
||||
filePath := p
|
||||
if isAtomicUploadEnabled() {
|
||||
filePath = getUploadTempFilePath(p)
|
||||
if isAtomicUploadEnabled() && c.connection.fs.IsAtomicUploadSupported() {
|
||||
filePath = c.connection.fs.GetAtomicUploadPath(p)
|
||||
}
|
||||
stat, statErr := os.Stat(p)
|
||||
if os.IsNotExist(statErr) {
|
||||
stat, statErr := c.connection.fs.Stat(p)
|
||||
if c.connection.fs.IsNotExist(statErr) {
|
||||
if !c.connection.User.HasPerm(dataprovider.PermUpload, path.Dir(uploadFilePath)) {
|
||||
err := fmt.Errorf("Permission denied")
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath)
|
||||
|
@ -248,8 +251,8 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
|
|||
|
||||
if statErr != nil {
|
||||
c.connection.Log(logger.LevelError, logSenderSCP, "error performing file stat %#v: %v", p, statErr)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
c.sendErrorMessage(statErr.Error())
|
||||
return statErr
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
|
@ -266,8 +269,8 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error
|
|||
return err
|
||||
}
|
||||
|
||||
if isAtomicUploadEnabled() {
|
||||
err = os.Rename(p, filePath)
|
||||
if isAtomicUploadEnabled() && c.connection.fs.IsAtomicUploadSupported() {
|
||||
err = c.connection.fs.Rename(p, filePath)
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelError, logSenderSCP, "error renaming existing file for atomic upload, source: %#v, dest: %#v, err: %v",
|
||||
p, filePath, err)
|
||||
|
@ -315,14 +318,14 @@ func (c *scpCommand) handleRecursiveDownload(dirPath string, stat os.FileInfo) e
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := ioutil.ReadDir(dirPath)
|
||||
files, err := c.connection.fs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
}
|
||||
var dirs []string
|
||||
for _, file := range files {
|
||||
filePath := c.connection.User.GetRelativePath(filepath.Join(dirPath, file.Name()))
|
||||
filePath := c.connection.fs.GetRelativePath(c.connection.fs.Join(dirPath, file.Name()), c.connection.User.GetHomeDir())
|
||||
if file.Mode().IsRegular() || file.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
err = c.handleDownload(filePath)
|
||||
if err != nil {
|
||||
|
@ -419,7 +422,7 @@ func (c *scpCommand) handleDownload(filePath string) error {
|
|||
|
||||
updateConnectionActivity(c.connection.ID)
|
||||
|
||||
p, err := c.connection.buildPath(filePath)
|
||||
p, err := c.connection.fs.ResolvePath(filePath, c.connection.User.GetHomeDir())
|
||||
if err != nil {
|
||||
err := fmt.Errorf("Invalid file path")
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading file: %#v, invalid file path", filePath)
|
||||
|
@ -428,7 +431,7 @@ func (c *scpCommand) handleDownload(filePath string) error {
|
|||
}
|
||||
|
||||
var stat os.FileInfo
|
||||
if stat, err = os.Stat(p); os.IsNotExist(err) {
|
||||
if stat, err = c.connection.fs.Stat(p); err != nil {
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "error downloading file: %#v, err: %v", p, err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
|
@ -452,7 +455,7 @@ func (c *scpCommand) handleDownload(filePath string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
file, err := os.Open(p)
|
||||
file, r, cancelFn, err := c.connection.fs.Open(p)
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelError, logSenderSCP, "could not open file %#v for reading: %v", p, err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
|
@ -461,6 +464,9 @@ func (c *scpCommand) handleDownload(filePath string) error {
|
|||
|
||||
transfer := Transfer{
|
||||
file: file,
|
||||
readerAt: r,
|
||||
writerAt: nil,
|
||||
cancelFn: cancelFn,
|
||||
path: p,
|
||||
start: time.Now(),
|
||||
bytesSent: 0,
|
||||
|
@ -608,12 +614,12 @@ func (c *scpCommand) getNextUploadProtocolMessage() (string, error) {
|
|||
|
||||
func (c *scpCommand) createDir(dirPath string) error {
|
||||
var err error
|
||||
if err = os.Mkdir(dirPath, 0777); err != nil {
|
||||
if err = c.connection.fs.Mkdir(dirPath); err != nil {
|
||||
c.connection.Log(logger.LevelError, logSenderSCP, "error creating dir: %v", dirPath)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return err
|
||||
}
|
||||
utils.SetPathPermissions(dirPath, c.connection.User.GetUID(), c.connection.User.GetGID())
|
||||
vfs.SetPathPermissions(c.connection.fs, dirPath, c.connection.User.GetUID(), c.connection.User.GetGID())
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -668,8 +674,8 @@ func (c *scpCommand) getFileUploadDestPath(scpDestPath, fileName string) string
|
|||
// but if scpDestPath is an existing directory then we put the uploaded file
|
||||
// inside that directory this is as scp command works, for example:
|
||||
// scp fileName.txt user@127.0.0.1:/existing_dir
|
||||
if p, err := c.connection.buildPath(scpDestPath); err == nil {
|
||||
if stat, err := os.Stat(p); err == nil {
|
||||
if p, err := c.connection.fs.ResolvePath(scpDestPath, c.connection.User.GetHomeDir()); err == nil {
|
||||
if stat, err := c.connection.fs.Stat(p); err == nil {
|
||||
if stat.IsDir() {
|
||||
return path.Join(scpDestPath, fileName)
|
||||
}
|
||||
|
|
|
@ -266,6 +266,14 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
|
|||
loginType = sconn.Permissions.Extensions["login_type"]
|
||||
connectionID := hex.EncodeToString(sconn.SessionID())
|
||||
|
||||
fs, err := user.GetFilesystem(connectionID)
|
||||
|
||||
if err != nil {
|
||||
logger.Warn(logSender, "", "could create filesystem for user %#v err: %v", user.Username, err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
connection := Connection{
|
||||
ID: connectionID,
|
||||
User: user,
|
||||
|
@ -275,7 +283,11 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
|
|||
lastActivity: time.Now(),
|
||||
netConn: conn,
|
||||
channel: nil,
|
||||
fs: fs,
|
||||
}
|
||||
|
||||
connection.fs.CheckRootPath(user.GetHomeDir(), user.Username, user.GetUID(), user.GetGID())
|
||||
|
||||
connection.Log(logger.LevelInfo, logSender, "User id: %d, logged in with: %#v, username: %#v, home_dir: %#v remote addr: %#v",
|
||||
user.ID, loginType, user.Username, user.HomeDir, remoteAddr.String())
|
||||
dataprovider.UpdateLastLogin(dataProvider, user)
|
||||
|
@ -368,14 +380,6 @@ func loginUser(user dataprovider.User, loginType string, remoteAddr string) (*ss
|
|||
logger.Debug(logSender, "", "cannot login user %#v, remote address is not allowed: %v", user.Username, remoteAddr)
|
||||
return nil, fmt.Errorf("Login is not allowed from this address: %v", remoteAddr)
|
||||
}
|
||||
if _, err := os.Stat(user.HomeDir); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(user.HomeDir, 0777)
|
||||
logger.Debug(logSender, "", "home directory %#v for user %#v does not exist, try to create, mkdir error: %v",
|
||||
user.HomeDir, user.Username, err)
|
||||
if err == nil {
|
||||
utils.SetPathPermissions(user.HomeDir, user.GetUID(), user.GetGID())
|
||||
}
|
||||
}
|
||||
|
||||
json, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
|
|
|
@ -307,7 +307,7 @@ func GetConnectionsStats() []ConnectionStatus {
|
|||
StartTime: utils.GetTimeAsMsSinceEpoch(t.start),
|
||||
Size: size,
|
||||
LastActivity: utils.GetTimeAsMsSinceEpoch(t.lastActivity),
|
||||
Path: c.User.GetRelativePath(t.path),
|
||||
Path: c.fs.GetRelativePath(t.path, c.User.GetHomeDir()),
|
||||
}
|
||||
conn.Transfers = append(conn.Transfers, connTransfer)
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
@ -1017,6 +1018,45 @@ func TestLoginUserExpiration(t *testing.T) {
|
|||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginInvalidFs(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("this test is not available on Windows")
|
||||
}
|
||||
config.LoadConfig(configDir, "")
|
||||
providerConf := config.GetProviderConf()
|
||||
if providerConf.Driver != dataprovider.SQLiteDataProviderName {
|
||||
t.Skip("this test require sqlite provider")
|
||||
}
|
||||
dbPath := providerConf.Name
|
||||
if !filepath.IsAbs(dbPath) {
|
||||
dbPath = filepath.Join(configDir, dbPath)
|
||||
}
|
||||
usePubKey := true
|
||||
u := getTestUser(usePubKey)
|
||||
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
// we update the database using sqlite3 CLI since we cannot add an user with an invalid config
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
updateUserQuery := fmt.Sprintf("UPDATE users SET filesystem='{\"provider\":1}' WHERE id=%v", user.ID)
|
||||
cmd := exec.Command("sqlite3", dbPath, updateUserQuery)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v, cmd out: %v", err, string(out))
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_, err = getSftpClient(user, usePubKey)
|
||||
if err == nil {
|
||||
t.Error("login must fail, the user has an invalid filesystem config")
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove user: %v", err)
|
||||
}
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestLoginWithIPFilters(t *testing.T) {
|
||||
usePubKey := true
|
||||
u := getTestUser(usePubKey)
|
||||
|
@ -2864,52 +2904,53 @@ func TestRootDirCommands(t *testing.T) {
|
|||
func TestRelativePaths(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
path := filepath.Join(user.HomeDir, "/")
|
||||
rel := user.GetRelativePath(path)
|
||||
fs := vfs.NewOsFs("")
|
||||
rel := fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "//")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "../..")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "../../../../../")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "/..")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "/../../../..")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, ".")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "somedir")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/somedir" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
path = filepath.Join(user.HomeDir, "/somedir/subdir")
|
||||
rel = user.GetRelativePath(path)
|
||||
rel = fs.GetRelativePath(path, user.GetHomeDir())
|
||||
if rel != "/somedir/subdir" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
|
|
|
@ -21,12 +21,14 @@ import (
|
|||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/metrics"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var (
|
||||
errQuotaExceeded = errors.New("denying write due to space limit")
|
||||
errPermissionDenied = errors.New("Permission denied. You don't have the permissions to execute this command")
|
||||
errQuotaExceeded = errors.New("denying write due to space limit")
|
||||
errPermissionDenied = errors.New("Permission denied. You don't have the permissions to execute this command")
|
||||
errUnsupportedConfig = errors.New("command unsupported for this configuration")
|
||||
)
|
||||
|
||||
type sshCommand struct {
|
||||
|
@ -101,6 +103,9 @@ func (c *sshCommand) handle() error {
|
|||
}
|
||||
|
||||
func (c *sshCommand) handleHashCommands() error {
|
||||
if !vfs.IsLocalOsFs(c.connection.fs) {
|
||||
return c.sendErrorResponse(errUnsupportedConfig)
|
||||
}
|
||||
var h hash.Hash
|
||||
if c.command == "md5sum" {
|
||||
h = md5.New()
|
||||
|
@ -125,14 +130,14 @@ func (c *sshCommand) handleHashCommands() error {
|
|||
response = fmt.Sprintf("%x -\n", h.Sum(nil))
|
||||
} else {
|
||||
sshPath := c.getDestPath()
|
||||
path, err := c.connection.buildPath(sshPath)
|
||||
fsPath, err := c.connection.fs.ResolvePath(sshPath, c.connection.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return c.sendErrorResponse(err)
|
||||
}
|
||||
if !c.connection.User.HasPerm(dataprovider.PermListItems, sshPath) {
|
||||
return c.sendErrorResponse(errPermissionDenied)
|
||||
}
|
||||
hash, err := computeHashForFile(h, path)
|
||||
hash, err := computeHashForFile(h, fsPath)
|
||||
if err != nil {
|
||||
return c.sendErrorResponse(err)
|
||||
}
|
||||
|
@ -144,6 +149,9 @@ func (c *sshCommand) handleHashCommands() error {
|
|||
}
|
||||
|
||||
func (c *sshCommand) executeSystemCommand(command systemCommand) error {
|
||||
if !vfs.IsLocalOsFs(c.connection.fs) {
|
||||
return c.sendErrorResponse(errUnsupportedConfig)
|
||||
}
|
||||
if c.connection.User.QuotaFiles > 0 && c.connection.User.UsedQuotaFiles > c.connection.User.QuotaFiles {
|
||||
return c.sendErrorResponse(errQuotaExceeded)
|
||||
}
|
||||
|
@ -288,7 +296,7 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) {
|
|||
if len(c.args) > 0 {
|
||||
var err error
|
||||
sshPath := c.getDestPath()
|
||||
path, err = c.connection.buildPath(sshPath)
|
||||
path, err = c.connection.fs.ResolvePath(sshPath, c.connection.User.GetHomeDir())
|
||||
if err != nil {
|
||||
return command, err
|
||||
}
|
||||
|
@ -331,7 +339,7 @@ func (c *sshCommand) rescanHomeDir() error {
|
|||
var numFiles int
|
||||
var size int64
|
||||
if AddQuotaScan(c.connection.User.Username) {
|
||||
numFiles, size, _, err = utils.ScanDirContents(c.connection.User.HomeDir)
|
||||
numFiles, size, err = c.connection.fs.ScanDirContents(c.connection.User.HomeDir)
|
||||
if err != nil {
|
||||
c.connection.Log(logger.LevelWarn, logSenderSSH, "error scanning user home dir %#v: %v", c.connection.User.HomeDir, err)
|
||||
} else {
|
||||
|
@ -389,7 +397,7 @@ func (c *sshCommand) sendExitStatus(err error) {
|
|||
if err == nil && c.command != "scp" {
|
||||
realPath := c.getDestPath()
|
||||
if len(realPath) > 0 {
|
||||
p, err := c.connection.buildPath(realPath)
|
||||
p, err := c.connection.fs.ResolvePath(realPath, c.connection.User.GetHomeDir())
|
||||
if err == nil {
|
||||
realPath = p
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/metrics"
|
||||
"github.com/eikenb/pipeat"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -26,6 +27,9 @@ var (
|
|||
// It implements the io Reader and Writer interface to handle files downloads and uploads
|
||||
type Transfer struct {
|
||||
file *os.File
|
||||
writerAt *pipeat.PipeWriterAt
|
||||
readerAt *pipeat.PipeReaderAt
|
||||
cancelFn func()
|
||||
path string
|
||||
start time.Time
|
||||
bytesSent int64
|
||||
|
@ -52,6 +56,9 @@ func (t *Transfer) TransferError(err error) {
|
|||
return
|
||||
}
|
||||
t.transferError = err
|
||||
if t.cancelFn != nil {
|
||||
t.cancelFn()
|
||||
}
|
||||
elapsed := time.Since(t.start).Nanoseconds() / 1000000
|
||||
logger.Warn(logSender, t.connectionID, "Unexpected error for transfer, path: %#v, error: \"%v\" bytes sent: %v, "+
|
||||
"bytes received: %v transfer running since %v ms", t.path, t.transferError, t.bytesSent, t.bytesReceived, elapsed)
|
||||
|
@ -61,7 +68,13 @@ func (t *Transfer) TransferError(err error) {
|
|||
// It handles download bandwidth throttling too
|
||||
func (t *Transfer) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
t.lastActivity = time.Now()
|
||||
readed, e := t.file.ReadAt(p, off)
|
||||
var readed int
|
||||
var e error
|
||||
if t.readerAt != nil {
|
||||
readed, e = t.readerAt.ReadAt(p, off)
|
||||
} else {
|
||||
readed, e = t.file.ReadAt(p, off)
|
||||
}
|
||||
t.lock.Lock()
|
||||
t.bytesSent += int64(readed)
|
||||
t.lock.Unlock()
|
||||
|
@ -82,7 +95,13 @@ func (t *Transfer) WriteAt(p []byte, off int64) (n int, err error) {
|
|||
t.TransferError(err)
|
||||
return 0, err
|
||||
}
|
||||
written, e := t.file.WriteAt(p, off)
|
||||
var written int
|
||||
var e error
|
||||
if t.writerAt != nil {
|
||||
written, e = t.writerAt.WriteAt(p, off)
|
||||
} else {
|
||||
written, e = t.file.WriteAt(p, off)
|
||||
}
|
||||
t.lock.Lock()
|
||||
t.bytesReceived += int64(written)
|
||||
t.lock.Unlock()
|
||||
|
@ -105,14 +124,14 @@ func (t *Transfer) Close() error {
|
|||
if t.isFinished {
|
||||
return errTransferClosed
|
||||
}
|
||||
err := t.file.Close()
|
||||
err := t.closeIO()
|
||||
t.isFinished = true
|
||||
numFiles := 0
|
||||
if t.isNewFile {
|
||||
numFiles = 1
|
||||
}
|
||||
t.checkDownloadSize()
|
||||
if t.transferType == transferUpload && t.file.Name() != t.path {
|
||||
if t.transferType == transferUpload && t.file != nil && t.file.Name() != t.path {
|
||||
if t.transferError == nil || uploadMode == uploadModeAtomicWithResume {
|
||||
err = os.Rename(t.file.Name(), t.path)
|
||||
logger.Debug(logSender, t.connectionID, "atomic upload completed, rename: %#v -> %#v, error: %v",
|
||||
|
@ -150,6 +169,18 @@ func (t *Transfer) Close() error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (t *Transfer) closeIO() error {
|
||||
var err error
|
||||
if t.writerAt != nil {
|
||||
err = t.writerAt.Close()
|
||||
} else if t.readerAt != nil {
|
||||
err = t.readerAt.Close()
|
||||
} else {
|
||||
err = t.file.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Transfer) checkDownloadSize() {
|
||||
if t.transferType == transferDownload && t.transferError == nil && t.bytesSent < t.expectedSize {
|
||||
t.transferError = fmt.Errorf("incomplete download: %v/%v bytes transferred", t.bytesSent, t.expectedSize)
|
||||
|
|
6
sql/mysql/20200116.sql
Normal file
6
sql/mysql/20200116.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filesystem to user
|
||||
--
|
||||
ALTER TABLE `users` ADD COLUMN `filesystem` longtext NULL;
|
||||
COMMIT;
|
6
sql/pgsql/20200116.sql
Normal file
6
sql/pgsql/20200116.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filesystem to user
|
||||
--
|
||||
ALTER TABLE "users" ADD COLUMN "filesystem" text NULL;
|
||||
COMMIT;
|
9
sql/sqlite/20200116.sql
Normal file
9
sql/sqlite/20200116.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
BEGIN;
|
||||
--
|
||||
-- Add field filesystem to user
|
||||
--
|
||||
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL, "filesystem" text NULL);
|
||||
INSERT INTO "new__users" ("id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", "filters", "filesystem") SELECT "id", "username", "password", "public_keys", "home_dir", "uid", "gid", "max_sessions", "quota_size", "quota_files", "permissions", "used_quota_size", "used_quota_files", "last_quota_update", "upload_bandwidth", "download_bandwidth", "expiration_date", "last_login", "status", "filters", NULL FROM "users";
|
||||
DROP TABLE "users";
|
||||
ALTER TABLE "new__users" RENAME TO "users";
|
||||
COMMIT;
|
|
@ -191,6 +191,58 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idFilesystem" class="col-sm-2 col-form-label">Storage</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" id="idFilesystem" name="fs_provider">
|
||||
<option value="0" {{if eq .User.FsConfig.Provider 0 }}selected{{end}}>local</option>
|
||||
<option value="1" {{if eq .User.FsConfig.Provider 1 }}selected{{end}}>S3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idS3Bucket" class="col-sm-2 col-form-label">S3 Bucket</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3Bucket" name="s3_bucket" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.Bucket}}" maxlength="255">
|
||||
</div>
|
||||
<div class="col-sm-2"></div>
|
||||
<label for="idS3Region" class="col-sm-2 col-form-label">S3 Region</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3Region" name="s3_region" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.Region}}" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idS3AccessKey" class="col-sm-2 col-form-label">S3 Access Key</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3AccessKey" name="s3_access_key" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.AccessKey}}" maxlength="255">
|
||||
</div>
|
||||
<div class="col-sm-2"></div>
|
||||
<label for="idS3AccessSecret" class="col-sm-2 col-form-label">S3 Access Secret</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3AccessSecret" name="s3_access_secret" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.AccessSecret}}" maxlength="1000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idS3StorageClass" class="col-sm-2 col-form-label">S3 Storage Class</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3StorageClass" name="s3_storage_class" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.StorageClass}}" maxlength="1000">
|
||||
</div>
|
||||
<div class="col-sm-2"></div>
|
||||
<label for="idS3Endpoint" class="col-sm-2 col-form-label">S3 Endpoint</label>
|
||||
<div class="col-sm-3">
|
||||
<input type="text" class="form-control" id="idS3Endpoint" name="s3_endpoint" placeholder=""
|
||||
value="{{.User.FsConfig.S3Config.Endpoint}}" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
|
||||
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
|
||||
</form>
|
||||
|
|
125
utils/utils.go
125
utils/utils.go
|
@ -2,15 +2,16 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
)
|
||||
|
||||
const logSender = "utils"
|
||||
|
@ -46,49 +47,6 @@ func GetTimeFromMsecSinceEpoch(msec int64) time.Time {
|
|||
return time.Unix(0, msec*1000000)
|
||||
}
|
||||
|
||||
// ScanDirContents returns the number of files contained in a directory, their size and a slice with the file paths
|
||||
func ScanDirContents(path string) (int, int64, []string, error) {
|
||||
var numFiles int
|
||||
var size int64
|
||||
var fileList []string
|
||||
var err error
|
||||
numFiles = 0
|
||||
size = 0
|
||||
isDir, err := isDirectory(path)
|
||||
if err == nil && isDir {
|
||||
err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil && info.Mode().IsRegular() {
|
||||
size += info.Size()
|
||||
numFiles++
|
||||
fileList = append(fileList, path)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
return numFiles, size, fileList, err
|
||||
}
|
||||
|
||||
func isDirectory(path string) (bool, error) {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return fileInfo.IsDir(), err
|
||||
}
|
||||
|
||||
// SetPathPermissions call os.Chown on unix, it does nothing on windows
|
||||
func SetPathPermissions(path string, uid int, gid int) {
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
logger.Warn(logSender, "", "error chowning path %v: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAppVersion returns VersionInfo struct
|
||||
func GetAppVersion() VersionInfo {
|
||||
return versionInfo
|
||||
|
@ -144,3 +102,74 @@ func GetIPFromRemoteAddress(remoteAddress string) string {
|
|||
}
|
||||
return remoteAddress
|
||||
}
|
||||
|
||||
// NilIfEmpty returns nil if the input string is empty
|
||||
func NilIfEmpty(s string) *string {
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// EncryptData encrypts data using the given key
|
||||
func EncryptData(data string) (string, error) {
|
||||
var result string
|
||||
key := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return result, err
|
||||
}
|
||||
keyHex := hex.EncodeToString(key)
|
||||
block, err := aes.NewCipher([]byte(keyHex))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return result, err
|
||||
}
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(data), nil)
|
||||
result = fmt.Sprintf("$aes$%s$%x", keyHex, ciphertext)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// RemoveDecryptionKey returns encrypted data without the decryption key
|
||||
func RemoveDecryptionKey(encryptData string) string {
|
||||
vals := strings.Split(encryptData, "$")
|
||||
if len(vals) == 4 {
|
||||
return fmt.Sprintf("$%v$%v", vals[1], vals[3])
|
||||
}
|
||||
return encryptData
|
||||
}
|
||||
|
||||
// DecryptData decrypts data encrypted using EncryptData
|
||||
func DecryptData(data string) (string, error) {
|
||||
var result string
|
||||
vals := strings.Split(data, "$")
|
||||
if len(vals) != 4 {
|
||||
return "", errors.New("data to decrypt is not in the correct format")
|
||||
}
|
||||
key := vals[2]
|
||||
encrypted, err := hex.DecodeString(vals[3])
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
|
288
vfs/osfs.go
Normal file
288
vfs/osfs.go
Normal file
|
@ -0,0 +1,288 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/eikenb/pipeat"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
const (
|
||||
// osFsName is the name for the local Fs implementation
|
||||
osFsName = "osfs"
|
||||
)
|
||||
|
||||
// OsFs is a Fs implementation that uses functions provided by the os package.
|
||||
type OsFs struct {
|
||||
name string
|
||||
connectionID string
|
||||
}
|
||||
|
||||
// NewOsFs returns an OsFs object that allows to interact with local Os filesystem
|
||||
func NewOsFs(connectionID string) Fs {
|
||||
return &OsFs{
|
||||
name: osFsName,
|
||||
connectionID: connectionID}
|
||||
}
|
||||
|
||||
// Name returns the name for the Fs implementation
|
||||
func (fs OsFs) Name() string {
|
||||
return fs.name
|
||||
}
|
||||
|
||||
// ConnectionID returns the SSH connection ID associated to this Fs implementation
|
||||
func (fs OsFs) ConnectionID() string {
|
||||
return fs.connectionID
|
||||
}
|
||||
|
||||
// Stat returns a FileInfo describing the named file
|
||||
func (OsFs) Stat(name string) (os.FileInfo, error) {
|
||||
return os.Stat(name)
|
||||
}
|
||||
|
||||
// Lstat returns a FileInfo describing the named file
|
||||
func (OsFs) Lstat(name string) (os.FileInfo, error) {
|
||||
return os.Lstat(name)
|
||||
}
|
||||
|
||||
// Open opens the named file for reading
|
||||
func (OsFs) Open(name string) (*os.File, *pipeat.PipeReaderAt, func(), error) {
|
||||
f, err := os.Open(name)
|
||||
return f, nil, nil, err
|
||||
}
|
||||
|
||||
// Create creates or opens the named file for writing
|
||||
func (OsFs) Create(name string, flag int) (*os.File, *pipeat.PipeWriterAt, func(), error) {
|
||||
var err error
|
||||
var f *os.File
|
||||
if flag == 0 {
|
||||
f, err = os.Create(name)
|
||||
} else {
|
||||
f, err = os.OpenFile(name, flag, 0666)
|
||||
|
||||
}
|
||||
return f, nil, nil, err
|
||||
}
|
||||
|
||||
// Rename renames (moves) source to target
|
||||
func (OsFs) Rename(source, target string) error {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
func (OsFs) Remove(name string, isDir bool) error {
|
||||
return os.Remove(name)
|
||||
}
|
||||
|
||||
// Mkdir creates a new directory with the specified name and default permissions
|
||||
func (OsFs) Mkdir(name string) error {
|
||||
return os.Mkdir(name, 0777)
|
||||
}
|
||||
|
||||
// Symlink creates source as a symbolic link to target.
|
||||
func (OsFs) Symlink(source, target string) error {
|
||||
return os.Symlink(source, target)
|
||||
}
|
||||
|
||||
// Chown changes the numeric uid and gid of the named file.
|
||||
func (OsFs) Chown(name string, uid int, gid int) error {
|
||||
return os.Chown(name, uid, gid)
|
||||
}
|
||||
|
||||
// Chmod changes the mode of the named file to mode
|
||||
func (OsFs) Chmod(name string, mode os.FileMode) error {
|
||||
return os.Chmod(name, mode)
|
||||
}
|
||||
|
||||
// Chtimes changes the access and modification times of the named file
|
||||
func (OsFs) Chtimes(name string, atime, mtime time.Time) error {
|
||||
return os.Chtimes(name, atime, mtime)
|
||||
}
|
||||
|
||||
// ReadDir reads the directory named by dirname and returns
|
||||
// a list of directory entries.
|
||||
func (OsFs) ReadDir(dirname string) ([]os.FileInfo, error) {
|
||||
return ioutil.ReadDir(dirname)
|
||||
}
|
||||
|
||||
// IsUploadResumeSupported returns true if upload resume is supported
|
||||
func (OsFs) IsUploadResumeSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAtomicUploadSupported returns true if atomic upload is supported
|
||||
func (OsFs) IsAtomicUploadSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// IsNotExist returns a boolean indicating whether the error is known to
|
||||
// report that a file or directory does not exist
|
||||
func (OsFs) IsNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
// IsPermission returns a boolean indicating whether the error is known to
|
||||
// report that permission is denied.
|
||||
func (OsFs) IsPermission(err error) bool {
|
||||
return os.IsPermission(err)
|
||||
}
|
||||
|
||||
// CheckRootPath creates the specified root directory if it does not exists
|
||||
func (fs OsFs) CheckRootPath(rootPath, username string, uid int, gid int) bool {
|
||||
var err error
|
||||
if _, err = fs.Stat(rootPath); fs.IsNotExist(err) {
|
||||
err = os.MkdirAll(rootPath, 0777)
|
||||
fsLog(fs, logger.LevelDebug, "root directory %#v for user %#v does not exist, try to create, mkdir error: %v",
|
||||
rootPath, username, err)
|
||||
if err == nil {
|
||||
SetPathPermissions(fs, rootPath, uid, gid)
|
||||
}
|
||||
}
|
||||
return (err == nil)
|
||||
}
|
||||
|
||||
// ScanDirContents returns the number of files contained in a directory and
|
||||
// their size
|
||||
func (fs OsFs) ScanDirContents(dirPath string) (int, int64, error) {
|
||||
numFiles := 0
|
||||
size := int64(0)
|
||||
isDir, err := IsDirectory(fs, dirPath)
|
||||
if err == nil && isDir {
|
||||
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil && info.Mode().IsRegular() {
|
||||
size += info.Size()
|
||||
numFiles++
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
return numFiles, size, err
|
||||
}
|
||||
|
||||
// GetAtomicUploadPath returns the path to use for an atomic upload
|
||||
func (OsFs) GetAtomicUploadPath(name string) string {
|
||||
dir := filepath.Dir(name)
|
||||
guid := xid.New().String()
|
||||
return filepath.Join(dir, ".sftpgo-upload."+guid+"."+filepath.Base(name))
|
||||
}
|
||||
|
||||
// GetRelativePath returns the path for a file relative to the user's home dir.
|
||||
// This is the path as seen by SFTP users
|
||||
func (OsFs) GetRelativePath(name, rootPath string) string {
|
||||
rel, err := filepath.Rel(rootPath, filepath.Clean(name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
rel = ""
|
||||
}
|
||||
return "/" + filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
// Join joins any number of path elements into a single path
|
||||
func (OsFs) Join(elem ...string) string {
|
||||
return filepath.Join(elem...)
|
||||
}
|
||||
|
||||
// ResolvePath returns the matching filesystem path for the specified sftp path
|
||||
func (fs OsFs) ResolvePath(sftpPath, rootPath string) (string, error) {
|
||||
if !filepath.IsAbs(rootPath) {
|
||||
return "", fmt.Errorf("Invalid root path: %v", rootPath)
|
||||
}
|
||||
r := filepath.Clean(filepath.Join(rootPath, sftpPath))
|
||||
p, err := filepath.EvalSymlinks(r)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
} else if os.IsNotExist(err) {
|
||||
// The requested path doesn't exist, so at this point we need to iterate up the
|
||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||
_, err = fs.findFirstExistingDir(r, rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "error resolving not existent path: %#v", err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
err = fs.isSubDir(p, rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, rootPath, err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (fs *OsFs) findNonexistentDirs(path, rootPath string) ([]string, error) {
|
||||
results := []string{}
|
||||
cleanPath := filepath.Clean(path)
|
||||
parent := filepath.Dir(cleanPath)
|
||||
_, err := os.Stat(parent)
|
||||
|
||||
for os.IsNotExist(err) {
|
||||
results = append(results, parent)
|
||||
parent = filepath.Dir(parent)
|
||||
_, err = os.Stat(parent)
|
||||
}
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
p, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
err = fs.isSubDir(p, rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "error finding non existing dir: %v", err)
|
||||
}
|
||||
return results, err
|
||||
}
|
||||
|
||||
func (fs *OsFs) findFirstExistingDir(path, rootPath string) (string, error) {
|
||||
results, err := fs.findNonexistentDirs(path, rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "unable to find non existent dirs: %v", err)
|
||||
return "", err
|
||||
}
|
||||
var parent string
|
||||
if len(results) > 0 {
|
||||
lastMissingDir := results[len(results)-1]
|
||||
parent = filepath.Dir(lastMissingDir)
|
||||
} else {
|
||||
parent = rootPath
|
||||
}
|
||||
p, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileInfo, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !fileInfo.IsDir() {
|
||||
return "", fmt.Errorf("resolved path is not a dir: %#v", p)
|
||||
}
|
||||
err = fs.isSubDir(p, rootPath)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (fs *OsFs) isSubDir(sub, rootPath string) error {
|
||||
// rootPath must exist and it is already a validated absolute path
|
||||
parent, err := filepath.EvalSymlinks(rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "invalid home dir %#v: %v", rootPath, err)
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(sub, parent) {
|
||||
err = fmt.Errorf("path %#v is not inside: %#v", sub, parent)
|
||||
fsLog(fs, logger.LevelWarn, "error: %v ", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
60
vfs/s3fileinfo.go
Normal file
60
vfs/s3fileinfo.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// S3FileInfo implements os.FileInfo for a file in S3.
|
||||
type S3FileInfo struct {
|
||||
name string
|
||||
sizeInBytes int64
|
||||
modTime time.Time
|
||||
mode os.FileMode
|
||||
sys interface{}
|
||||
}
|
||||
|
||||
// NewS3FileInfo creates file info.
|
||||
func NewS3FileInfo(name string, isDirectory bool, sizeInBytes int64, modTime time.Time) S3FileInfo {
|
||||
mode := os.FileMode(0644)
|
||||
if isDirectory {
|
||||
mode = os.FileMode(0755) | os.ModeDir
|
||||
}
|
||||
|
||||
return S3FileInfo{
|
||||
name: name,
|
||||
sizeInBytes: sizeInBytes,
|
||||
modTime: modTime,
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// Name provides the base name of the file.
|
||||
func (fi S3FileInfo) Name() string {
|
||||
return fi.name
|
||||
}
|
||||
|
||||
// Size provides the length in bytes for a file.
|
||||
func (fi S3FileInfo) Size() int64 {
|
||||
return fi.sizeInBytes
|
||||
}
|
||||
|
||||
// Mode provides the file mode bits
|
||||
func (fi S3FileInfo) Mode() os.FileMode {
|
||||
return fi.mode
|
||||
}
|
||||
|
||||
// ModTime provides the last modification time.
|
||||
func (fi S3FileInfo) ModTime() time.Time {
|
||||
return fi.modTime
|
||||
}
|
||||
|
||||
// IsDir provides the abbreviation for Mode().IsDir()
|
||||
func (fi S3FileInfo) IsDir() bool {
|
||||
return fi.mode&os.ModeDir != 0
|
||||
}
|
||||
|
||||
// Sys provides the underlying data source (can return nil)
|
||||
func (fi S3FileInfo) Sys() interface{} {
|
||||
return fi.getFileInfoSys()
|
||||
}
|
28
vfs/s3fileinfo_unix.go
Normal file
28
vfs/s3fileinfo_unix.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
// +build !windows
|
||||
|
||||
package vfs
|
||||
|
||||
import "syscall"
|
||||
|
||||
import "os"
|
||||
|
||||
var (
|
||||
defaultUID, defaultGID int
|
||||
)
|
||||
|
||||
func init() {
|
||||
defaultUID = os.Getuid()
|
||||
defaultGID = os.Getuid()
|
||||
if defaultUID < 0 {
|
||||
defaultUID = 65534
|
||||
}
|
||||
if defaultGID < 0 {
|
||||
defaultGID = 65534
|
||||
}
|
||||
}
|
||||
|
||||
func (fi S3FileInfo) getFileInfoSys() interface{} {
|
||||
return &syscall.Stat_t{
|
||||
Uid: uint32(defaultUID),
|
||||
Gid: uint32(defaultGID)}
|
||||
}
|
7
vfs/s3fileinfo_windows.go
Normal file
7
vfs/s3fileinfo_windows.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package vfs
|
||||
|
||||
import "syscall"
|
||||
|
||||
func (fi S3FileInfo) getFileInfoSys() interface{} {
|
||||
return syscall.Win32FileAttributeData{}
|
||||
}
|
491
vfs/s3fs.go
Normal file
491
vfs/s3fs.go
Normal file
|
@ -0,0 +1,491 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/eikenb/pipeat"
|
||||
)
|
||||
|
||||
// S3FsConfig defines the configuration for S3fs
|
||||
type S3FsConfig struct {
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
AccessKey string `json:"access_key,omitempty"`
|
||||
AccessSecret string `json:"access_secret,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
}
|
||||
|
||||
// S3Fs is a Fs implementation for Amazon S3 compatible object storage.
|
||||
type S3Fs struct {
|
||||
connectionID string
|
||||
localTempDir string
|
||||
config S3FsConfig
|
||||
svc *s3.S3
|
||||
ctxTimeout time.Duration
|
||||
ctxLongTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewS3Fs returns an S3Fs object that allows to interact with an s3 compatible
|
||||
// object storage
|
||||
func NewS3Fs(connectionID, localTempDir string, config S3FsConfig) (Fs, error) {
|
||||
fs := S3Fs{
|
||||
connectionID: connectionID,
|
||||
localTempDir: localTempDir,
|
||||
config: config,
|
||||
ctxTimeout: 30 * time.Second,
|
||||
ctxLongTimeout: 300 * time.Second,
|
||||
}
|
||||
if err := ValidateS3FsConfig(&fs.config); err != nil {
|
||||
return fs, err
|
||||
}
|
||||
accessSecret, err := utils.DecryptData(fs.config.AccessSecret)
|
||||
if err != nil {
|
||||
return fs, err
|
||||
}
|
||||
fs.config.AccessSecret = accessSecret
|
||||
awsConfig := &aws.Config{
|
||||
Region: aws.String(fs.config.Region),
|
||||
Credentials: credentials.NewStaticCredentials(fs.config.AccessKey, fs.config.AccessSecret, ""),
|
||||
}
|
||||
//config.WithLogLevel(aws.LogDebugWithHTTPBody)
|
||||
if len(fs.config.Endpoint) > 0 {
|
||||
awsConfig.Endpoint = aws.String(fs.config.Endpoint)
|
||||
awsConfig.S3ForcePathStyle = aws.Bool(true)
|
||||
}
|
||||
sess, err := session.NewSession(awsConfig)
|
||||
if err != nil {
|
||||
return fs, err
|
||||
}
|
||||
fs.svc = s3.New(sess)
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// Name returns the name for the Fs implementation
|
||||
func (fs S3Fs) Name() string {
|
||||
return fmt.Sprintf("S3Fs bucket: %#v", fs.config.Bucket)
|
||||
}
|
||||
|
||||
// ConnectionID returns the SSH connection ID associated to this Fs implementation
|
||||
func (fs S3Fs) ConnectionID() string {
|
||||
return fs.connectionID
|
||||
}
|
||||
|
||||
// Stat returns a FileInfo describing the named file
|
||||
func (fs S3Fs) Stat(name string) (os.FileInfo, error) {
|
||||
var result S3FileInfo
|
||||
if name == "/" || name == "." {
|
||||
err := fs.checkIfBucketExists()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return NewS3FileInfo(name, true, 0, time.Time{}), nil
|
||||
}
|
||||
prefix := path.Dir(name)
|
||||
if prefix == "/" || prefix == "." {
|
||||
prefix = ""
|
||||
} else {
|
||||
prefix = strings.TrimPrefix(prefix, "/")
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
}
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
err := fs.svc.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
Delimiter: aws.String("/"),
|
||||
}, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, p := range page.CommonPrefixes {
|
||||
if fs.isEqual(p.Prefix, name) {
|
||||
result = NewS3FileInfo(name, true, 0, time.Time{})
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, fileObject := range page.Contents {
|
||||
if fs.isEqual(fileObject.Key, name) {
|
||||
objectSize := *fileObject.Size
|
||||
objectModTime := *fileObject.LastModified
|
||||
isDir := strings.HasSuffix(*fileObject.Key, "/")
|
||||
result = NewS3FileInfo(name, isDir, objectSize, objectModTime)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err == nil && len(result.Name()) == 0 {
|
||||
err = errors.New("404 no such file or directory")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Lstat returns a FileInfo describing the named file
|
||||
func (fs S3Fs) Lstat(name string) (os.FileInfo, error) {
|
||||
return fs.Stat(name)
|
||||
}
|
||||
|
||||
// Open opens the named file for reading
|
||||
func (fs S3Fs) Open(name string) (*os.File, *pipeat.PipeReaderAt, func(), error) {
|
||||
r, w, err := pipeat.AsyncWriterPipeInDir(fs.localTempDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
downloader := s3manager.NewDownloaderWithClient(fs.svc)
|
||||
go func() {
|
||||
defer cancelFn()
|
||||
key := name
|
||||
n, err := downloader.DownloadWithContext(ctx, w, &s3.GetObjectInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
fsLog(fs, logger.LevelDebug, "download completed, path: %#v size: %v, err: %v", name, n, err)
|
||||
w.CloseWithError(err)
|
||||
}()
|
||||
return nil, r, cancelFn, nil
|
||||
}
|
||||
|
||||
// Create creates or opens the named file for writing
|
||||
func (fs S3Fs) Create(name string, flag int) (*os.File, *pipeat.PipeWriterAt, func(), error) {
|
||||
r, w, err := pipeat.PipeInDir(fs.localTempDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
uploader := s3manager.NewUploaderWithClient(fs.svc)
|
||||
go func() {
|
||||
defer cancelFn()
|
||||
key := name
|
||||
response, err := uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Key: aws.String(key),
|
||||
Body: r,
|
||||
StorageClass: utils.NilIfEmpty(fs.config.StorageClass),
|
||||
})
|
||||
fsLog(fs, logger.LevelDebug, "upload completed, path: %#v, response: %v, err: %v", name, response, err)
|
||||
r.CloseWithError(err)
|
||||
}()
|
||||
return nil, w, cancelFn, nil
|
||||
}
|
||||
|
||||
// Rename renames (moves) source to target.
|
||||
// We don't support renaming non empty directories since we should
|
||||
// rename all the contents too and this could take long time: think
|
||||
// about directories with thousands of files, for each file we should
|
||||
// execute a CopyObject call.
|
||||
func (fs S3Fs) Rename(source, target string) error {
|
||||
if source == target {
|
||||
return nil
|
||||
}
|
||||
fi, err := fs.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
copySource := fs.Join(fs.config.Bucket, source)
|
||||
if fi.IsDir() {
|
||||
contents, err := fs.ReadDir(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contents) > 0 {
|
||||
return fmt.Errorf("Cannot rename non empty directory: %#v", source)
|
||||
}
|
||||
if !strings.HasSuffix(copySource, "/") {
|
||||
copySource += "/"
|
||||
}
|
||||
if !strings.HasSuffix(target, "/") {
|
||||
target += "/"
|
||||
}
|
||||
}
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
_, err = fs.svc.CopyObjectWithContext(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
CopySource: aws.String(copySource),
|
||||
Key: aws.String(target),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.Remove(source, fi.IsDir())
|
||||
}
|
||||
|
||||
// Remove removes the named file or (empty) directory.
|
||||
func (fs S3Fs) Remove(name string, isDir bool) error {
|
||||
if isDir {
|
||||
contents, err := fs.ReadDir(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contents) > 0 {
|
||||
return fmt.Errorf("Cannot remove non empty directory: %#v", name)
|
||||
}
|
||||
if !strings.HasSuffix(name, "/") {
|
||||
name += "/"
|
||||
}
|
||||
}
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
_, err := fs.svc.DeleteObjectWithContext(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Key: aws.String(name),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Mkdir creates a new directory with the specified name and default permissions
|
||||
func (fs S3Fs) Mkdir(name string) error {
|
||||
_, err := fs.Stat(name)
|
||||
if !fs.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(name, "/") {
|
||||
name += "/"
|
||||
}
|
||||
_, w, _, err := fs.Create(name, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// Symlink creates source as a symbolic link to target.
|
||||
func (S3Fs) Symlink(source, target string) error {
|
||||
return errors.New("403 symlinks are not supported")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Chmod changes the mode of the named file to mode.
|
||||
// Silently ignored.
|
||||
func (S3Fs) Chmod(name string, mode os.FileMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// ReadDir reads the directory named by dirname and returns
|
||||
// a list of directory entries.
|
||||
func (fs S3Fs) ReadDir(dirname string) ([]os.FileInfo, error) {
|
||||
var result []os.FileInfo
|
||||
// dirname deve essere già cleaned
|
||||
prefix := ""
|
||||
if dirname != "/" && dirname != "." {
|
||||
prefix = strings.TrimPrefix(dirname, "/")
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
}
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
err := fs.svc.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
Delimiter: aws.String("/"),
|
||||
}, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, p := range page.CommonPrefixes {
|
||||
name, isDir := fs.resolve(p.Prefix, prefix)
|
||||
result = append(result, NewS3FileInfo(name, isDir, 0, time.Time{}))
|
||||
}
|
||||
for _, fileObject := range page.Contents {
|
||||
objectSize := *fileObject.Size
|
||||
objectModTime := *fileObject.LastModified
|
||||
name, isDir := fs.resolve(fileObject.Key, prefix)
|
||||
if len(name) == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, NewS3FileInfo(name, isDir, objectSize, objectModTime))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IsUploadResumeSupported returns true if upload resume is supported.
|
||||
// SFTP Resume is not supported on S3
|
||||
func (S3Fs) IsUploadResumeSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAtomicUploadSupported returns true if atomic upload is supported.
|
||||
// S3 uploads are already atomic, we don't need to upload to a temporary
|
||||
// file
|
||||
func (S3Fs) IsAtomicUploadSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsNotExist returns a boolean indicating whether the error is known to
|
||||
// report that a file or directory does not exist
|
||||
func (S3Fs) IsNotExist(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if aerr, ok := err.(awserr.Error); ok {
|
||||
if aerr.Code() == s3.ErrCodeNoSuchKey {
|
||||
return true
|
||||
}
|
||||
if aerr.Code() == s3.ErrCodeNoSuchBucket {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if multierr, ok := err.(s3manager.MultiUploadFailure); ok {
|
||||
if multierr.Code() == s3.ErrCodeNoSuchKey {
|
||||
return true
|
||||
}
|
||||
if multierr.Code() == s3.ErrCodeNoSuchBucket {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(err.Error(), "404")
|
||||
}
|
||||
|
||||
// IsPermission returns a boolean indicating whether the error is known to
|
||||
// report that permission is denied.
|
||||
func (S3Fs) IsPermission(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "403")
|
||||
}
|
||||
|
||||
// CheckRootPath creates the specified root directory if it does not exists
|
||||
func (fs S3Fs) CheckRootPath(rootPath, username string, uid int, gid int) bool {
|
||||
// we need a local directory for temporary files
|
||||
osFs := NewOsFs(fs.ConnectionID())
|
||||
osFs.CheckRootPath(fs.localTempDir, username, uid, gid)
|
||||
err := fs.checkIfBucketExists()
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if !fs.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
input := &s3.CreateBucketInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
}
|
||||
_, err = fs.svc.CreateBucketWithContext(ctx, input)
|
||||
fsLog(fs, logger.LevelDebug, "bucket %#v for user %#v does not exists, try to create, error: %v",
|
||||
fs.config.Bucket, username, err)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ScanDirContents returns the number of files contained in the bucket,
|
||||
// and their size
|
||||
func (fs S3Fs) ScanDirContents(dirPath string) (int, int64, error) {
|
||||
numFiles := 0
|
||||
size := int64(0)
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxLongTimeout))
|
||||
defer cancelFn()
|
||||
err := fs.svc.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Prefix: aws.String(""),
|
||||
}, func(page *s3.ListObjectsV2Output, lastPage bool) bool {
|
||||
for _, fileObject := range page.Contents {
|
||||
numFiles++
|
||||
size += *fileObject.Size
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return numFiles, size, err
|
||||
}
|
||||
|
||||
// GetAtomicUploadPath returns the path to use for an atomic upload.
|
||||
// S3 uploads are already atomic, we never call this method for S3
|
||||
func (S3Fs) GetAtomicUploadPath(name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRelativePath returns the path for a file relative to the user's home dir.
|
||||
// This is the path as seen by SFTP users
|
||||
func (S3Fs) GetRelativePath(name, rootPath string) string {
|
||||
rel := name
|
||||
if name == "." {
|
||||
rel = ""
|
||||
}
|
||||
if !strings.HasPrefix(rel, "/") {
|
||||
return "/" + rel
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// Join joins any number of path elements into a single path
|
||||
func (S3Fs) Join(elem ...string) string {
|
||||
return path.Join(elem...)
|
||||
}
|
||||
|
||||
// ResolvePath returns the matching filesystem path for the specified sftp path
|
||||
func (fs S3Fs) ResolvePath(sftpPath, rootPath string) (string, error) {
|
||||
return sftpPath, nil
|
||||
}
|
||||
|
||||
func (fs *S3Fs) resolve(name *string, prefix string) (string, bool) {
|
||||
result := strings.TrimPrefix(*name, prefix)
|
||||
isDir := strings.HasSuffix(result, "/")
|
||||
if isDir {
|
||||
result = strings.TrimSuffix(result, "/")
|
||||
}
|
||||
if strings.Contains(result, "/") {
|
||||
i := strings.Index(result, "/")
|
||||
isDir = true
|
||||
result = result[:i]
|
||||
}
|
||||
return result, isDir
|
||||
}
|
||||
|
||||
func (fs *S3Fs) isEqual(s3Key *string, sftpName string) bool {
|
||||
if *s3Key == sftpName {
|
||||
return true
|
||||
}
|
||||
if "/"+*s3Key == sftpName {
|
||||
return true
|
||||
}
|
||||
if "/"+*s3Key == sftpName+"/" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (fs *S3Fs) checkIfBucketExists() error {
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
_, err := fs.svc.HeadBucketWithContext(ctx, &s3.HeadBucketInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (fs *S3Fs) getObjectDetails(key string) (*s3.HeadObjectOutput, error) {
|
||||
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
|
||||
defer cancelFn()
|
||||
input := &s3.HeadObjectInput{
|
||||
Bucket: aws.String(fs.config.Bucket),
|
||||
Key: aws.String(key),
|
||||
}
|
||||
return fs.svc.HeadObjectWithContext(ctx, input)
|
||||
}
|
100
vfs/vfs.go
Normal file
100
vfs/vfs.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package vfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/eikenb/pipeat"
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// Fs defines the interface for filesystems backends
|
||||
type Fs interface {
|
||||
Name() string
|
||||
ConnectionID() string
|
||||
Stat(name string) (os.FileInfo, error)
|
||||
Lstat(name string) (os.FileInfo, error)
|
||||
Open(name string) (*os.File, *pipeat.PipeReaderAt, func(), error)
|
||||
Create(name string, flag int) (*os.File, *pipeat.PipeWriterAt, func(), error)
|
||||
Rename(source, target string) error
|
||||
Remove(name string, isDir bool) error
|
||||
Mkdir(name string) error
|
||||
Symlink(source, target string) error
|
||||
Chown(name string, uid int, gid int) error
|
||||
Chmod(name string, mode os.FileMode) error
|
||||
Chtimes(name string, atime, mtime time.Time) error
|
||||
ReadDir(dirname string) ([]os.FileInfo, error)
|
||||
IsUploadResumeSupported() bool
|
||||
IsAtomicUploadSupported() bool
|
||||
CheckRootPath(rootPath, username string, uid int, gid int) bool
|
||||
ResolvePath(sftpPath, rootPath string) (string, error)
|
||||
IsNotExist(err error) bool
|
||||
IsPermission(err error) bool
|
||||
ScanDirContents(dirPath string) (int, int64, error)
|
||||
GetAtomicUploadPath(name string) string
|
||||
GetRelativePath(name, rootPath string) string
|
||||
Join(elem ...string) string
|
||||
}
|
||||
|
||||
// IsDirectory checks if a path exists and is a directory
|
||||
func IsDirectory(fs Fs, path string) (bool, error) {
|
||||
fileInfo, err := fs.Stat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return fileInfo.IsDir(), err
|
||||
}
|
||||
|
||||
// GetSFTPError returns an sftp error from a filesystem error
|
||||
func GetSFTPError(fs Fs, err error) error {
|
||||
if fs.IsNotExist(err) {
|
||||
return sftp.ErrSSHFxNoSuchFile
|
||||
} else if fs.IsPermission(err) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
} else if err != nil {
|
||||
return sftp.ErrSSHFxFailure
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsLocalOsFs returns true if fs is the local filesystem implementation
|
||||
func IsLocalOsFs(fs Fs) bool {
|
||||
return fs.Name() == osFsName
|
||||
}
|
||||
|
||||
// ValidateS3FsConfig returns nil if the specified s3 config is valid, otherwise an error
|
||||
func ValidateS3FsConfig(config *S3FsConfig) error {
|
||||
if len(config.Bucket) == 0 {
|
||||
return errors.New("bucket cannot be empty")
|
||||
}
|
||||
if len(config.Region) == 0 {
|
||||
return errors.New("region cannot be empty")
|
||||
}
|
||||
if len(config.AccessKey) == 0 {
|
||||
return errors.New("access_key cannot be empty")
|
||||
}
|
||||
if len(config.AccessSecret) == 0 {
|
||||
return errors.New("access_secret cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPathPermissions calls fs.Chown.
|
||||
// It does nothing for local filesystem on windows
|
||||
func SetPathPermissions(fs Fs, path string, uid int, gid int) {
|
||||
if IsLocalOsFs(fs) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := fs.Chown(path, uid, gid); err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "error chowning path %v: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func fsLog(fs Fs, level logger.LogLevel, format string, v ...interface{}) {
|
||||
logger.Log(level, fs.Name(), fs.ConnectionID(), format, v...)
|
||||
}
|
Loading…
Reference in a new issue