diff --git a/README.md b/README.md index ecc9d77b..6bb7c550 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Full featured and highly configurable SFTP server - Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled. - Per user files/folders ownership: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only). - Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address. +- Virtual folders are supported: directories outside the user home directory can be exposed as virtual folders. - Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete. - Automatically terminating idle connections. - Atomic uploads are configurable. @@ -666,7 +667,8 @@ For each account the following properties can be configured: - `public_keys` array of public keys. At least one public key or the password is mandatory. - `status` 1 means "active", 0 "inactive". An inactive account cannot login. - `expiration_date` expiration date as unix timestamp in milliseconds. An expired account cannot login. 0 means no expiration. -- `home_dir` The user cannot upload or download files outside this directory. Must be an absolute path. +- `home_dir` the user cannot upload or download files outside this directory. Must be an absolute path. +- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login - `uid`, `gid`. If sftpgo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows and if sftpgo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs sftpgo. - `max_sessions` maximum concurrent sessions. 0 means unlimited. - `quota_size` maximum size allowed as bytes. 0 means unlimited. @@ -833,10 +835,19 @@ The logs can be divided into the following categories: - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive` or `no_auth_tryed` - `error` string. Optional error description -### Brute force protection +## Brute force protection The **connection failed logs** can be used for integration in tools such as [Fail2ban](http://www.fail2ban.org/). Example of [jails](./fail2ban/jails) and [filters](./fail2ban/filters) working with `systemd`/`journald` are available in fail2ban directory. +## Performance + +SFTPGo can easily saturate a Gigabit connection, on low end hardware, with no special configurations and this is generally enough for most use cases. + +The main bootlenecks are the encryption and the messages authentication, so if you can use a fast cipher with implicit message authentication, for example `aes128-gcm@openssh.com`, you will get a big performace boost. + +There is an open [issue](https://github.com/drakkan/sftpgo/issues/69) with some other suggestions to improve performance and some comparisons against OpenSSH. + + ## Acknowledgements - [pkg/sftp](https://github.com/pkg/sftp) diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 597df5dc..71fabf2c 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -500,6 +500,80 @@ func buildUserHomeDir(user *User) { } } +func isVirtualDirOverlapped(dir1, dir2 string) bool { + if dir1 == dir2 { + return true + } + if len(dir1) > len(dir2) { + if strings.HasPrefix(dir1, dir2+"/") { + return true + } + } + if len(dir2) > len(dir1) { + if strings.HasPrefix(dir2, dir1+"/") { + return true + } + } + return false +} + +func isMappedDirOverlapped(dir1, dir2 string) bool { + if dir1 == dir2 { + return true + } + if len(dir1) > len(dir2) { + if strings.HasPrefix(dir1, dir2+string(os.PathSeparator)) { + return true + } + } + if len(dir2) > len(dir1) { + if strings.HasPrefix(dir2, dir1+string(os.PathSeparator)) { + return true + } + } + return false +} + +func validateVirtualFolders(user *User) error { + if len(user.VirtualFolders) == 0 || user.FsConfig.Provider != 0 { + user.VirtualFolders = []vfs.VirtualFolder{} + return nil + } + var virtualFolders []vfs.VirtualFolder + mappedPaths := make(map[string]string) + for _, v := range user.VirtualFolders { + cleanedVPath := filepath.ToSlash(path.Clean(v.VirtualPath)) + if !path.IsAbs(cleanedVPath) || cleanedVPath == "/" { + return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v", v.VirtualPath)} + } + cleanedMPath := filepath.Clean(v.MappedPath) + if !filepath.IsAbs(cleanedMPath) { + return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v", v.MappedPath)} + } + if isMappedDirOverlapped(cleanedMPath, user.GetHomeDir()) { + return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v cannot be inside or contain the user home dir %#v", + v.MappedPath, user.GetHomeDir())} + } + virtualFolders = append(virtualFolders, vfs.VirtualFolder{ + VirtualPath: cleanedVPath, + MappedPath: cleanedMPath, + }) + for k, virtual := range mappedPaths { + if isMappedDirOverlapped(k, cleanedMPath) { + return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v overlaps with mapped folder %#v", + v.MappedPath, k)} + } + if isVirtualDirOverlapped(virtual, cleanedVPath) { + return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v overlaps with virtual folder %#v", + v.VirtualPath, virtual)} + } + } + mappedPaths[cleanedMPath] = cleanedVPath + } + user.VirtualFolders = virtualFolders + return nil +} + func validatePermissions(user *User) error { if len(user.Permissions) == 0 { return &ValidationError{err: "please grant some permissions to this user"} @@ -659,6 +733,9 @@ func validateUser(user *User) error { if err := validateFilesystemConfig(user); err != nil { return err } + if err := validateVirtualFolders(user); err != nil { + return err + } if user.Status < 0 || user.Status > 1 { return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)} } diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 4e69d3f3..a7bc52b3 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -19,6 +19,7 @@ const ( "`last_login` bigint(20) NOT NULL, `status` int(11) NOT NULL, `filters` longtext DEFAULT NULL, " + "`filesystem` longtext DEFAULT NULL);" mysqlSchemaTableSQL = "CREATE TABLE `schema_version` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);" + mysqlUsersV2SQL = "ALTER TABLE `{{users}}` ADD COLUMN `virtual_folders` longtext NULL;" ) // MySQLProvider auth provider for MySQL/MariaDB database @@ -143,5 +144,36 @@ func (p MySQLProvider) initializeDatabase() error { } func (p MySQLProvider) migrateDatabase() error { - return sqlCommonMigrateDatabase(p.dbHandle) + dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle) + if err != nil { + return err + } + if dbVersion.Version == sqlDatabaseVersion { + providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version) + return nil + } + if dbVersion.Version == 1 { + return updateMySQLDatabaseFrom1To2(p.dbHandle) + } + return nil +} + +func updateMySQLDatabaseFrom1To2(dbHandle *sql.DB) error { + providerLog(logger.LevelInfo, "updating database version: 1 -> 2") + sql := strings.Replace(mysqlUsersV2SQL, "{{users}}", config.UsersTable, 1) + tx, err := dbHandle.Begin() + if err != nil { + return err + } + _, err = tx.Exec(sql) + if err != nil { + tx.Rollback() + return err + } + err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2) + if err != nil { + tx.Rollback() + return err + } + return tx.Commit() } diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 56967294..3241082e 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -17,6 +17,7 @@ const ( "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL, "filesystem" text NULL);` pgsqlSchemaTableSQL = `CREATE TABLE "schema_version" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL);` + pgsqlUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;` ) // PGSQLProvider auth provider for PostgreSQL database @@ -141,5 +142,36 @@ func (p PGSQLProvider) initializeDatabase() error { } func (p PGSQLProvider) migrateDatabase() error { - return sqlCommonMigrateDatabase(p.dbHandle) + dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle) + if err != nil { + return err + } + if dbVersion.Version == sqlDatabaseVersion { + providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version) + return nil + } + if dbVersion.Version == 1 { + return updatePGSQLDatabaseFrom1To2(p.dbHandle) + } + return nil +} + +func updatePGSQLDatabaseFrom1To2(dbHandle *sql.DB) error { + providerLog(logger.LevelInfo, "updating database version: 1 -> 2") + sql := strings.Replace(pgsqlUsersV2SQL, "{{users}}", config.UsersTable, 1) + tx, err := dbHandle.Begin() + if err != nil { + return err + } + _, err = tx.Exec(sql) + if err != nil { + tx.Rollback() + return err + } + err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2) + if err != nil { + tx.Rollback() + return err + } + return tx.Commit() } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 4e7e17cc..1208d447 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -9,10 +9,11 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( - sqlDatabaseVersion = 1 + sqlDatabaseVersion = 2 initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);" ) @@ -171,9 +172,13 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + virtualFolders, err := user.GetVirtualFoldersAsJSON() + 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), - string(fsConfig)) + string(fsConfig), string(virtualFolders)) return err } @@ -205,9 +210,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + virtualFolders, err := user.GetVirtualFoldersAsJSON() + 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), string(fsConfig), user.ID) + string(filters), string(fsConfig), string(virtualFolders), user.ID) return err } @@ -281,6 +290,25 @@ func sqlCommonGetUsers(limit int, offset int, order string, username string, dbH return users, err } +func updateUserPermissionsFromDb(user *User, permissions string) error { + var err error + perms := make(map[string][]string) + err = json.Unmarshal([]byte(permissions), &perms) + if err == nil { + user.Permissions = perms + } else { + // compatibility layer: until version 0.9.4 permissions were a string list + var list []string + err = json.Unmarshal([]byte(permissions), &list) + if err != nil { + return err + } + perms["/"] = list + user.Permissions = perms + } + return err +} + func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { var user User var permissions sql.NullString @@ -288,16 +316,19 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { var publicKey sql.NullString var filters sql.NullString var fsConfig sql.NullString + var virtualFolders 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, &fsConfig) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig, + &virtualFolders) } 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, &fsConfig) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig, + &virtualFolders) } if err != nil { if err == sql.ErrNoRows { @@ -308,6 +339,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { if password.Valid { user.Password = password.String } + // we can have a empty string or an invalid json in null string + // so we do a relaxed test if the field is optional, for example we + // populate public keys only if unmarshal does not return an error if publicKey.Valid { var list []string err = json.Unmarshal([]byte(publicKey.String), &list) @@ -316,18 +350,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { } } if permissions.Valid { - perms := make(map[string][]string) - err = json.Unmarshal([]byte(permissions.String), &perms) - if err == nil { - user.Permissions = perms - } else { - // compatibility layer: until version 0.9.4 permissions were a string list - var list []string - err = json.Unmarshal([]byte(permissions.String), &list) - if err == nil { - perms["/"] = list - user.Permissions = perms - } + err = updateUserPermissionsFromDb(&user, permissions.String) + if err != nil { + return user, err } } if filters.Valid { @@ -336,11 +361,6 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { if err == nil { user.Filters = userFilters } - } else { - user.Filters = UserFilters{ - AllowedIP: []string{}, - DeniedIP: []string{}, - } } if fsConfig.Valid { var fs Filesystem @@ -348,26 +368,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { if err == nil { user.FsConfig = fs } - } else { - user.FsConfig = Filesystem{ - Provider: 0, + } + if virtualFolders.Valid { + var list []vfs.VirtualFolder + err = json.Unmarshal([]byte(virtualFolders.String), &list) + if err == nil { + user.VirtualFolders = list } } return user, err } -func sqlCommonMigrateDatabase(dbHandle *sql.DB) error { - dbVersion, err := sqlCommonGetDatabaseVersion(dbHandle) - if err != nil { - return err - } - if dbVersion.Version == sqlDatabaseVersion { - providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version) - return nil - } - return nil -} - func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) { var result schemaVersion q := getDatabaseVersionQuery() @@ -382,7 +393,7 @@ func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) { return result, err } -func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error { +func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error { q := getUpdateDBVersionQuery() stmt, err := dbHandle.Prepare(q) if err != nil { @@ -390,7 +401,18 @@ func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error { return err } defer stmt.Close() - _, err = stmt.Exec(sqlDatabaseVersion) + _, err = stmt.Exec(version) + return err +} + +func sqlCommonUpdateDatabaseVersionWithTX(tx *sql.Tx, version int) error { + q := getUpdateDBVersionQuery() + stmt, err := tx.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(version) return err - } diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 45333003..d606dbec 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -18,6 +18,7 @@ NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_di "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL, "filesystem" text NULL);` sqliteSchemaTableSQL = `CREATE TABLE "schema_version" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL);` + sqliteUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;` ) // SQLiteProvider auth provider for SQLite database @@ -119,5 +120,26 @@ func (p SQLiteProvider) initializeDatabase() error { } func (p SQLiteProvider) migrateDatabase() error { - return sqlCommonMigrateDatabase(p.dbHandle) + dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle) + if err != nil { + return err + } + if dbVersion.Version == sqlDatabaseVersion { + providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version) + return nil + } + if dbVersion.Version == 1 { + return updateSQLiteDatabaseFrom1To2(p.dbHandle) + } + return nil +} + +func updateSQLiteDatabaseFrom1To2(dbHandle *sql.DB) error { + providerLog(logger.LevelInfo, "updating database version: 1 -> 2") + sql := strings.Replace(sqliteUsersV2SQL, "{{users}}", config.UsersTable, 1) + _, err := dbHandle.Exec(sql) + if err != nil { + return err + } + return sqlCommonUpdateDatabaseVersion(dbHandle, 2) } diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index bb40f5a7..3580af14 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -4,7 +4,8 @@ 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,filesystem" + "used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," + + "virtual_folders" ) func getSQLPlaceholders() []string { @@ -61,19 +62,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, - 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], + filesystem,virtual_folders) + VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%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[15]) + sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16]) } 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,filesystem=%v - WHERE id = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], + quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v, + virtual_folders=%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]) + sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15], + sqlPlaceholders[16]) } func getDeleteUserQuery() string { diff --git a/dataprovider/user.go b/dataprovider/user.go index 7b744888..71f859ab 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "net" + "os" "path" "path/filepath" "strconv" + "time" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" @@ -90,6 +92,8 @@ type User struct { PublicKeys []string `json:"public_keys,omitempty"` // The user cannot upload or download files outside this directory. Must be an absolute path HomeDir string `json:"home_dir"` + // Mapping between virtual paths and filesystem paths outside the home directory. Supported for local filesystem only + VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"` // If sftpgo runs as root system user then the created files and directories will be assigned to this system UID UID int `json:"uid"` // If sftpgo runs as root system user then the created files and directories will be assigned to this system GID @@ -129,7 +133,7 @@ func (u *User) GetFilesystem(connectionID string) (vfs.Fs, error) { config.CredentialFile = u.getGCSCredentialsFilePath() return vfs.NewGCSFs(connectionID, u.GetHomeDir(), config) } - return vfs.NewOsFs(connectionID, u.GetHomeDir()), nil + return vfs.NewOsFs(connectionID, u.GetHomeDir(), u.VirtualFolders), nil } // GetPermissionsForPath returns the permissions for the given path. @@ -144,19 +148,7 @@ func (u *User) GetPermissionsForPath(p string) []string { // fallback permissions permissions = perms } - sftpPath := filepath.ToSlash(p) - if !path.IsAbs(p) { - sftpPath = "/" + sftpPath - } - sftpPath = path.Clean(sftpPath) - dirsForPath := []string{sftpPath} - for { - if sftpPath == "/" { - break - } - sftpPath = path.Dir(sftpPath) - dirsForPath = append(dirsForPath, sftpPath) - } + dirsForPath := utils.GetDirsForSFTPPath(p) // dirsForPath contains all the dirs for a given path in reverse order // for example if the path is: /1/2/3/4 it contains: // [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ] @@ -170,6 +162,40 @@ func (u *User) GetPermissionsForPath(p string) []string { return permissions } +// AddVirtualDirs adds virtual folders, if defined, to the given files list +func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo { + if len(u.VirtualFolders) == 0 { + return list + } + for _, v := range u.VirtualFolders { + if path.Dir(v.VirtualPath) == sftpPath { + fi := vfs.NewFileInfo(path.Base(v.VirtualPath), true, 0, time.Time{}) + found := false + for index, f := range list { + if f.Name() == fi.Name() { + list[index] = fi + found = true + break + } + } + if !found { + list = append(list, fi) + } + } + } + return list +} + +// IsVirtualFolder returns true if the specified sftp path is a virtual folder +func (u *User) IsVirtualFolder(sftpPath string) bool { + for _, v := range u.VirtualFolders { + if sftpPath == v.VirtualPath { + return true + } + } + return false +} + // HasPerm returns true if the user has the given permission or any permission func (u *User) HasPerm(permission, path string) bool { perms := u.GetPermissionsForPath(path) @@ -259,6 +285,11 @@ func (u *User) GetFsConfigAsJSON() ([]byte, error) { return json.Marshal(u.FsConfig) } +// GetVirtualFoldersAsJSON returns the virtual folders as json byte array +func (u *User) GetVirtualFoldersAsJSON() ([]byte, error) { + return json.Marshal(u.VirtualFolders) +} + // GetUID returns a validate uid, suitable for use with os.Chown func (u *User) GetUID() int { if u.UID <= 0 || u.UID > 65535 { @@ -417,6 +448,8 @@ func (u User) GetDeniedIPAsString() string { func (u *User) getACopy() User { pubKeys := make([]string, len(u.PublicKeys)) copy(pubKeys, u.PublicKeys) + virtualFolders := make([]vfs.VirtualFolder, len(u.VirtualFolders)) + copy(virtualFolders, u.VirtualFolders) permissions := make(map[string][]string) for k, v := range u.Permissions { perms := make([]string, len(v)) @@ -456,6 +489,7 @@ func (u *User) getACopy() User { Password: u.Password, PublicKeys: pubKeys, HomeDir: u.HomeDir, + VirtualFolders: virtualFolders, UID: u.UID, GID: u.GID, MaxSessions: u.MaxSessions, diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 7cb6c7fb..08403c7f 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "path" + "path/filepath" "strconv" "strings" "time" @@ -422,10 +423,32 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error { if err := compareUserFsConfig(expected, actual); err != nil { return err } - + if err := compareUserVirtualFolders(expected, actual); err != nil { + return err + } return compareEqualsUserFields(expected, actual) } +func compareUserVirtualFolders(expected *dataprovider.User, actual *dataprovider.User) error { + if len(actual.VirtualFolders) != len(expected.VirtualFolders) { + return errors.New("Virtual folders mismatch") + } + for _, v := range actual.VirtualFolders { + found := false + for _, v1 := range expected.VirtualFolders { + if path.Clean(v.VirtualPath) == path.Clean(v1.VirtualPath) && + filepath.Clean(v.MappedPath) == filepath.Clean(v1.MappedPath) { + found = true + break + } + } + if !found { + return errors.New("Virtual folders mismatch") + } + } + return nil +} + func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User) error { if expected.FsConfig.Provider != actual.FsConfig.Provider { return errors.New("Fs provider mismatch") diff --git a/httpd/httpd.go b/httpd/httpd.go index ad899fdf..3a18729e 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -104,6 +104,7 @@ func (c Conf) Initialize(configDir string) error { Handler: router, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, MaxHeaderBytes: 1 << 16, // 64KB } if len(certificateFile) > 0 && len(certificateKeyFile) > 0 { diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 00a9df62..104a95de 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -33,6 +33,7 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( @@ -376,6 +377,132 @@ func TestAddUserInvalidFsConfig(t *testing.T) { } } +func TestAddUserInvalidVirtualFolders(t *testing.T) { + u := getTestUser() + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "vdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + _, _, err := httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: filepath.Join(u.GetHomeDir(), "mapped_dir"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: u.GetHomeDir(), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: filepath.Join(u.GetHomeDir(), ".."), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir2", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir2", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir2", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1/subdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1/../vdir1", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1/", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1/subdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + if err != nil { + t.Errorf("unexpected error adding user with invalid virtual folder: %v", err) + } +} + func TestUserPublicKey(t *testing.T) { u := getTestUser() invalidPubKey := "invalid" @@ -424,6 +551,15 @@ func TestUpdateUser(t *testing.T) { user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword} user.UploadBandwidth = 1024 user.DownloadBandwidth = 512 + user.VirtualFolders = nil + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }) + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir12/subdir", + MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }) user, _, err = httpd.UpdateUser(user, http.StatusOK) if err != nil { t.Errorf("unable to update user: %v", err) @@ -1560,6 +1696,7 @@ func TestWebUserAddMock(t *testing.T) { user.UploadBandwidth = 32 user.DownloadBandwidth = 64 user.UID = 1000 + mappedDir := filepath.Join(os.TempDir(), "mapped") form := make(url.Values) form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) @@ -1567,7 +1704,8 @@ func TestWebUserAddMock(t *testing.T) { form.Set("status", strconv.Itoa(user.Status)) form.Set("expiration_date", "") form.Set("permissions", "*") - form.Set("sub_dirs_permissions", " /subdir:list ,download ") + form.Set("sub_dirs_permissions", " /subdir::list ,download ") + form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ", mappedDir)) b, contentType, _ := getMultipartFormData(form, "", "") // test invalid url escape req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b) @@ -1696,7 +1834,16 @@ func TestWebUserAddMock(t *testing.T) { t.Error("permssions for /subdir does not match") } } else { - t.Errorf("user permissions must contains /somedir, actual: %v", newUser.Permissions) + t.Errorf("user permissions must contain /somedir, actual: %v", newUser.Permissions) + } + vfolderFoumd := false + for _, v := range newUser.VirtualFolders { + if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir { + vfolderFoumd = true + } + } + if !vfolderFoumd { + t.Errorf("virtual folders must contain /vdir, actual: %+v", newUser.VirtualFolders) } req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil) rr = executeRequest(req) @@ -1728,7 +1875,7 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("upload_bandwidth", "0") form.Set("download_bandwidth", "0") form.Set("permissions", "*") - form.Set("sub_dirs_permissions", "/otherdir : list ,upload ") + form.Set("sub_dirs_permissions", "/otherdir :: list ,upload ") form.Set("status", strconv.Itoa(user.Status)) form.Set("expiration_date", "2020-01-01 00:00:00") form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ") diff --git a/httpd/internal_test.go b/httpd/internal_test.go index b1522f99..14f0c6db 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -17,6 +17,7 @@ import ( "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" "github.com/go-chi/chi" ) @@ -103,6 +104,23 @@ func TestCheckUser(t *testing.T) { if err == nil { t.Errorf("Fs providers are not equal") } + actual.FsConfig.Provider = 0 + expected.VirtualFolders = append(expected.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir", + MappedPath: os.TempDir(), + }) + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Virtual folders are not equal") + } + actual.VirtualFolders = append(actual.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: "/vdir1", + MappedPath: os.TempDir(), + }) + err = checkUser(expected, actual) + if err == nil { + t.Errorf("Virtual folders are not equal") + } } func TestCompareUserFilters(t *testing.T) { diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 88cec3ed..4c9707b7 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.8.1 + version: 1.8.2 servers: - url: /api/v1 @@ -1036,6 +1036,17 @@ components: gcsconfig: $ref: '#/components/schemas/GCSConfig' description: Storage filesystem details + VirtualFolder: + type: object + properties: + virtual_path: + type: string + mapped_path: + type: string + required: + - virtual_path + - mapped_path + description: A virtual folder is a mapping between a SFTP/SCP virtual path and a filesystem path outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login. User: type: object properties: @@ -1071,6 +1082,12 @@ components: home_dir: type: string description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path + virtual_folders: + type: array + items: + $ref: '#/components/schemas/VirtualFolder' + nullable: true + description: mapping between virtual SFTP/SCP paths and filesystem paths outside the user home directory. Supported for local filesystem only uid: type: integer format: int32 diff --git a/httpd/web.go b/httpd/web.go index 9118f90b..7fb91367 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -15,6 +15,7 @@ import ( "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( @@ -186,13 +187,30 @@ func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error s renderTemplate(w, templateUser, data) } +func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { + var virtualFolders []vfs.VirtualFolder + formValue := r.Form.Get("virtual_folders") + for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") { + if strings.Contains(cleaned, "::") { + mapping := strings.Split(cleaned, "::") + if len(mapping) > 1 { + virtualFolders = append(virtualFolders, vfs.VirtualFolder{ + VirtualPath: strings.TrimSpace(mapping[0]), + MappedPath: strings.TrimSpace(mapping[1]), + }) + } + } + } + return virtualFolders +} + func getUserPermissionsFromPostFields(r *http.Request) map[string][]string { permissions := make(map[string][]string) permissions["/"] = r.Form["permissions"] subDirsPermsValue := r.Form.Get("sub_dirs_permissions") for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { - if strings.ContainsRune(cleaned, ':') { - dirPerms := strings.Split(cleaned, ":") + if strings.Contains(cleaned, "::") { + dirPerms := strings.Split(cleaned, "::") if len(dirPerms) > 1 { dir := dirPerms[0] dir = strings.TrimSpace(dir) @@ -335,6 +353,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { Password: r.Form.Get("password"), PublicKeys: publicKeys, HomeDir: r.Form.Get("home_dir"), + VirtualFolders: getVirtualFoldersFromPostFields(r), UID: uid, GID: gid, Permissions: getUserPermissionsFromPostFields(r), diff --git a/scripts/README.md b/scripts/README.md index 4576da2d..a019b579 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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" --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 --s3-key-prefix "vfolder/" --denied-login-methods "password" "keyboard-interactive" +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 --s3-key-prefix "vfolder/" --denied-login-methods "password" "keyboard-interactive" ``` Output: @@ -115,7 +115,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" --denied-login-methods "" --fs local +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" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1" "/vdir2::/tmp/mapped2" ``` Output: @@ -175,7 +175,17 @@ Output: "upload_bandwidth": 90, "used_quota_files": 0, "used_quota_size": 0, - "username": "test_username" + "username": "test_username", + "virtual_folders": [ + { + "mapped_path": "/tmp/mapped1", + "virtual_path": "/vdir1" + }, + { + "mapped_path": "/tmp/mapped2", + "virtual_path": "/vdir2" + } + ] } ``` @@ -227,7 +237,17 @@ Output: "upload_bandwidth": 90, "used_quota_files": 0, "used_quota_size": 0, - "username": "test_username" + "username": "test_username", + "virtual_folders": [ + { + "mapped_path": "/tmp/mapped1", + "virtual_path": "/vdir1" + }, + { + "mapped_path": "/tmp/mapped2", + "virtual_path": "/vdir2" + } + ] } ] ``` diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index 8f92ac4e..481e4ff1 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -76,7 +76,7 @@ class SFTPGoApiRequests: 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='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', - gcs_automatic_credentials='automatic', denied_login_methods=[]): + gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]): 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, @@ -92,6 +92,8 @@ class SFTPGoApiRequests: user.update({'home_dir':home_dir}) if permissions: user.update({'permissions':permissions}) + if virtual_folders: + user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)}) if allowed_ip or denied_ip or denied_login_methods: user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods)}) user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, @@ -100,15 +102,29 @@ class SFTPGoApiRequests: gcs_automatic_credentials)}) return user + def buildVirtualFolders(self, vfolders): + result = [] + for f in vfolders: + if '::' in f: + vpath = '' + mapped_path = '' + values = f.split('::') + if len(values) > 1: + vpath = values[0] + mapped_path = values[1] + if vpath and mapped_path: + result.append({"virtual_path":vpath, "mapped_path":mapped_path}) + return result + def buildPermissions(self, root_perms, subdirs_perms): permissions = {} if root_perms: permissions.update({'/':root_perms}) for p in subdirs_perms: - if ':' in p: + if '::' in p: directory = None values = [] - for value in p.split(':'): + for value in p.split('::'): if directory is None: directory = value else: @@ -172,12 +188,12 @@ class SFTPGoApiRequests: subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic', - denied_login_methods=[]): + denied_login_methods=[], virtual_folders=[]): 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, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, - gcs_credentials_file, gcs_automatic_credentials, denied_login_methods) + gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -186,12 +202,12 @@ class SFTPGoApiRequests: 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='', s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', - gcs_automatic_credentials='automatic', denied_login_methods=[]): + gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]): 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, fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class, - gcs_credentials_file, gcs_automatic_credentials, denied_login_methods) + gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders) r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -435,7 +451,9 @@ def addCommonUserArguments(parser): parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[], choices=['', 'publickey', 'password', 'keyboard-interactive'], help='Default: %(default)s') parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' - +'For example: "/somedir:list,download" "/otherdir/subdir:*" Default: %(default)s') + +'For example: "/somedir::list,download" "/otherdir/subdir::*" Default: %(default)s') + parser.add_argument('--virtual-folders', type=str, nargs='*', default=[], help='Virtual folder mapping. For example: ' + +'"/vpath::/home/adir" "/vpath::C:\adir", ignored for non local filesystems. Default: %(default)s') parser.add_argument('-U', '--upload-bandwidth', type=int, default=0, help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s') parser.add_argument('-D', '--download-bandwidth', type=int, default=0, @@ -578,7 +596,7 @@ if __name__ == '__main__': 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, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials, - args.denied_login_methods) + args.denied_login_methods, args.virtual_folders) 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, @@ -586,7 +604,8 @@ if __name__ == '__main__': 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, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class, - args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods) + args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods, + args.virtual_folders) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/sftpd/handler.go b/sftpd/handler.go index 00b822fd..8f27690a 100644 --- a/sftpd/handler.go +++ b/sftpd/handler.go @@ -206,14 +206,13 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) { } c.Log(logger.LevelDebug, logSender, "requested list file for dir: %#v", p) - files, err := c.fs.ReadDir(p) if err != nil { c.Log(logger.LevelWarn, logSender, "error listing directory: %+v", err) return nil, vfs.GetSFTPError(c.fs, err) } - return listerAt(files), nil + return listerAt(c.User.AddVirtualDirs(files, request.Filepath)), nil case "Stat": if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) { return nil, sftp.ErrSSHFxPermissionDenied @@ -306,6 +305,10 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string, reque c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed") return sftp.ErrSSHFxPermissionDenied } + if c.User.IsVirtualFolder(request.Filepath) || c.User.IsVirtualFolder(request.Target) { + c.Log(logger.LevelWarn, logSender, "renaming a virtual folder is not allowed") + return sftp.ErrSSHFxPermissionDenied + } if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) { return sftp.ErrSSHFxPermissionDenied } @@ -323,6 +326,10 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed") return sftp.ErrSSHFxPermissionDenied } + if c.User.IsVirtualFolder(request.Filepath) { + c.Log(logger.LevelWarn, logSender, "removing a virtual folder is not allowed: %#v", request.Filepath) + return sftp.ErrSSHFxPermissionDenied + } if !c.User.HasPerm(dataprovider.PermDelete, path.Dir(request.Filepath)) { return sftp.ErrSSHFxPermissionDenied } @@ -352,6 +359,10 @@ func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, requ c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed") return sftp.ErrSSHFxPermissionDenied } + if c.User.IsVirtualFolder(request.Target) { + c.Log(logger.LevelWarn, logSender, "symlinking a virtual folder is not allowed") + return sftp.ErrSSHFxPermissionDenied + } if !c.User.HasPerm(dataprovider.PermCreateSymlinks, path.Dir(request.Target)) { return sftp.ErrSSHFxPermissionDenied } @@ -368,6 +379,10 @@ func (c Connection) handleSFTPMkdir(dirPath string, request *sftp.Request) error if !c.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(request.Filepath)) { return sftp.ErrSSHFxPermissionDenied } + if c.User.IsVirtualFolder(request.Filepath) { + c.Log(logger.LevelWarn, logSender, "mkdir not allowed %#v is virtual folder is not allowed", request.Filepath) + return sftp.ErrSSHFxPermissionDenied + } if err := c.fs.Mkdir(dirPath); err != nil { c.Log(logger.LevelWarn, logSender, "error creating missing dir: %#v error: %+v", dirPath, err) return vfs.GetSFTPError(c.fs, err) diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 2a8caefd..b1009496 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -112,7 +112,7 @@ func (fs MockOsFs) Rename(source, target string) error { func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir string) vfs.Fs { return &MockOsFs{ - Fs: vfs.NewOsFs(connectionID, rootDir), + Fs: vfs.NewOsFs(connectionID, rootDir, nil), err: err, statErr: statErr, isAtomicUploadSupported: atomicUpload, @@ -388,7 +388,7 @@ func TestUploadFiles(t *testing.T) { oldUploadMode := uploadMode uploadMode = uploadModeAtomic c := Connection{ - fs: vfs.NewOsFs("123", os.TempDir()), + fs: vfs.NewOsFs("123", os.TempDir(), nil), } var flags sftp.FileOpenFlags flags.Write = true @@ -477,7 +477,7 @@ func TestSFTPCmdTargetPath(t *testing.T) { func TestGetSFTPErrorFromOSError(t *testing.T) { err := os.ErrNotExist - fs := vfs.NewOsFs("", os.TempDir()) + fs := vfs.NewOsFs("", os.TempDir(), nil) err = vfs.GetSFTPError(fs, err) if err != sftp.ErrSSHFxNoSuchFile { t.Errorf("unexpected error: %v", err) @@ -1287,7 +1287,7 @@ func TestSCPRecursiveDownloadErrors(t *testing.T) { connection := Connection{ channel: &mockSSHChannel, netConn: client, - fs: vfs.NewOsFs("123", os.TempDir()), + fs: vfs.NewOsFs("123", os.TempDir(), nil), } scpCommand := scpCommand{ sshCommand: sshCommand{ diff --git a/sftpd/scp.go b/sftpd/scp.go index fcbbc9e0..bce81390 100644 --- a/sftpd/scp.go +++ b/sftpd/scp.go @@ -652,7 +652,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) { c.sendErrorMessage(err.Error()) return size, name, err } - parts := strings.Split(command, " ") + parts := strings.SplitN(command, " ", 3) if len(parts) == 3 { size, err = strconv.ParseInt(parts[1], 10, 64) if err != nil { @@ -668,7 +668,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) { return size, name, err } } else { - err = fmt.Errorf("Error splitting upload message: %v", command) + err = fmt.Errorf("Error splitting upload message: %#v", command) c.connection.Log(logger.LevelWarn, logSenderSCP, "error: %v", err) c.sendErrorMessage(err.Error()) return size, name, err diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 33666fde..d9ef596c 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -1988,6 +1988,68 @@ func TestBandwidthAndConnections(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestVirtualFolders(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + mappedPath := filepath.Join(os.TempDir(), "vdir") + vdirPath := "/vdir" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: vdirPath, + MappedPath: mappedPath, + }) + os.MkdirAll(mappedPath, 0777) + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + client, err := getSftpClient(user, usePubKey) + if err != nil { + t.Errorf("unable to create sftp client: %v", err) + } else { + defer client.Close() + testFileSize := int64(131072) + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + err = createTestFile(testFilePath, testFileSize) + if err != nil { + t.Errorf("unable to create test file: %v", err) + } + localDownloadPath := filepath.Join(homeBasePath, "test_download.dat") + err = sftpUploadFile(testFilePath, path.Join(vdirPath, testFileName), testFileSize, client) + if err != nil { + t.Errorf("file upload error: %v", err) + } + err = sftpDownloadFile(path.Join(vdirPath, testFileName), localDownloadPath, testFileSize, client) + if err != nil { + t.Errorf("file download error: %v", err) + } + err = client.Rename(vdirPath, "new_name") + if err == nil { + t.Error("renaming a virtual folder must fail") + } + err = client.RemoveDirectory(vdirPath) + if err == nil { + t.Error("removing a virtual folder must fail") + } + err = client.Mkdir(vdirPath) + if err == nil { + t.Error("creating a virtual folder must fail") + } + err = client.Symlink(path.Join(vdirPath, testFileName), vdirPath) + if err == nil { + t.Error("symlink to a virtual folder must fail") + } + os.Remove(testFilePath) + os.Remove(localDownloadPath) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } + os.RemoveAll(user.GetHomeDir()) + os.RemoveAll(mappedPath) +} + func TestMissingFile(t *testing.T) { usePubKey := false u := getTestUser(usePubKey) @@ -3116,7 +3178,7 @@ func TestRootDirCommands(t *testing.T) { func TestRelativePaths(t *testing.T) { user := getTestUser(true) var path, rel string - filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())} + filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)} keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/" s3config := vfs.S3FsConfig{ KeyPrefix: keyPrefix, @@ -3187,7 +3249,7 @@ func TestResolvePaths(t *testing.T) { user := getTestUser(true) var path, resolved string var err error - filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())} + filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)} keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/" s3config := vfs.S3FsConfig{ KeyPrefix: keyPrefix, @@ -3243,6 +3305,80 @@ func TestResolvePaths(t *testing.T) { os.RemoveAll(user.GetHomeDir()) } +func TestVirtualRelativePaths(t *testing.T) { + user := getTestUser(true) + mappedPath := filepath.Join(os.TempDir(), "vdir") + vdirPath := "/vdir" + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: vdirPath, + MappedPath: mappedPath, + }) + os.MkdirAll(mappedPath, 0777) + fs := vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders) + rel := fs.GetRelativePath(mappedPath) + if rel != vdirPath { + t.Errorf("Unexpected relative path: %v", rel) + } + rel = fs.GetRelativePath(filepath.Join(mappedPath, "..")) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + // path outside home and virtual dir + rel = fs.GetRelativePath(filepath.Join(mappedPath, "../vdir1")) + if rel != "/" { + t.Errorf("Unexpected relative path: %v", rel) + } + rel = fs.GetRelativePath(filepath.Join(mappedPath, "../vdir/file.txt")) + if rel != "/vdir/file.txt" { + t.Errorf("Unexpected relative path: %v", rel) + } + rel = fs.GetRelativePath(filepath.Join(user.HomeDir, "vdir1/file.txt")) + if rel != "/vdir1/file.txt" { + t.Errorf("Unexpected relative path: %v", rel) + } +} + +func TestResolveVirtualPaths(t *testing.T) { + user := getTestUser(true) + mappedPath := filepath.Join(os.TempDir(), "vdir") + vdirPath := "/vdir" + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: vdirPath, + MappedPath: mappedPath, + }) + os.MkdirAll(mappedPath, 0777) + fs := vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders) + osFs := fs.(*vfs.OsFs) + b, f := osFs.GetFsPaths("/vdir/a.txt") + if b != mappedPath { + t.Errorf("unexpected base path: %#v expected: %#v", b, mappedPath) + } + if f != filepath.Join(mappedPath, "a.txt") { + t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(mappedPath, "a.txt")) + } + b, f = osFs.GetFsPaths("/vdir/sub with space & spécial chars/a.txt") + if b != mappedPath { + t.Errorf("unexpected base path: %#v expected: %#v", b, mappedPath) + } + if f != filepath.Join(mappedPath, "sub with space & spécial chars/a.txt") { + t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(mappedPath, "sub with space & spécial chars/a.txt")) + } + b, f = osFs.GetFsPaths("/vdir/../a.txt") + if b != user.GetHomeDir() { + t.Errorf("unexpected base path: %#v expected: %#v", b, user.GetHomeDir()) + } + if f != filepath.Join(user.GetHomeDir(), "a.txt") { + t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(user.GetHomeDir(), "a.txt")) + } + b, f = osFs.GetFsPaths("/vdir1/a.txt") + if b != user.GetHomeDir() { + t.Errorf("unexpected base path: %#v expected: %#v", b, user.GetHomeDir()) + } + if f != filepath.Join(user.GetHomeDir(), "/vdir1/a.txt") { + t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(user.GetHomeDir(), "/vdir1/a.txt")) + } +} + func TestUserPerms(t *testing.T) { user := getTestUser(true) user.Permissions = make(map[string][]string) @@ -3780,6 +3916,54 @@ func TestSCPRecursive(t *testing.T) { } } +func TestSCPVirtualFolders(t *testing.T) { + if len(scpPath) == 0 { + t.Skip("scp command not found, unable to execute this test") + } + usePubKey := true + u := getTestUser(usePubKey) + mappedPath := filepath.Join(os.TempDir(), "vdir") + vdirPath := "/vdir" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + VirtualPath: vdirPath, + MappedPath: mappedPath, + }) + os.MkdirAll(mappedPath, 0777) + user, _, err := httpd.AddUser(u, http.StatusOK) + if err != nil { + t.Errorf("unable to add user: %v", err) + } + testFileName := "test_file.dat" + testBaseDirName := "test_dir" + testBaseDirPath := filepath.Join(homeBasePath, testBaseDirName) + testBaseDirDownName := "test_dir_down" + testBaseDirDownPath := filepath.Join(homeBasePath, testBaseDirDownName) + testFilePath := filepath.Join(homeBasePath, testBaseDirName, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testBaseDirName, testBaseDirName, testFileName) + testFileSize := int64(131074) + createTestFile(testFilePath, testFileSize) + createTestFile(testFilePath1, testFileSize) + remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", vdirPath)) + remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath) + err = scpUpload(testBaseDirPath, remoteUpPath, true, false) + if err != nil { + t.Errorf("error uploading dir via scp: %v", err) + } + err = scpDownload(testBaseDirDownPath, remoteDownPath, true, true) + if err != nil { + t.Errorf("error downloading dir via scp: %v", err) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + if err != nil { + t.Errorf("unable to remove user: %v", err) + } + os.RemoveAll(testBaseDirPath) + os.RemoveAll(testBaseDirDownPath) + os.RemoveAll(user.GetHomeDir()) + os.RemoveAll(mappedPath) +} + func TestSCPPermsSubDirs(t *testing.T) { if len(scpPath) == 0 { t.Skip("scp command not found, unable to execute this test") diff --git a/sftpd/ssh_cmd.go b/sftpd/ssh_cmd.go index f4b2e664..fe83908e 100644 --- a/sftpd/ssh_cmd.go +++ b/sftpd/ssh_cmd.go @@ -11,8 +11,6 @@ import ( "io" "os" "os/exec" - "path" - "path/filepath" "strings" "sync" "time" @@ -360,11 +358,7 @@ func (c *sshCommand) getDestPath() string { } destPath := strings.Trim(c.args[len(c.args)-1], "'") destPath = strings.Trim(destPath, "\"") - destPath = filepath.ToSlash(destPath) - if !path.IsAbs(destPath) { - destPath = "/" + destPath - } - result := path.Clean(destPath) + result := utils.CleanSFTPPath(destPath) if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") { result += "/" } diff --git a/templates/user.html b/templates/user.html index 3e241a39..2c24d28e 100644 --- a/templates/user.html +++ b/templates/user.html @@ -102,11 +102,11 @@ - One directory per line as dir:perms, for example /somedir:list,download + One directory per line as dir::perms, for example /somedir::list,download @@ -119,6 +119,19 @@ +
+ +
+ + + One mapping per line as vpath::path, for example /vdir::/home/adir or /vdir::C:\adir, ignored for non local filesystems + +
+
+
diff --git a/utils/utils.go b/utils/utils.go index f38a6372..efba048c 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -17,6 +17,8 @@ import ( "io/ioutil" "net" "os" + "path" + "path/filepath" "strings" "time" @@ -248,3 +250,28 @@ func GenerateECDSAKeys(file string) error { } return ioutil.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0600) } + +// GetDirsForSFTPPath returns all the directory for the given path in reverse order +// for example if the path is: /1/2/3/4 it returns: +// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ] +func GetDirsForSFTPPath(p string) []string { + sftpPath := CleanSFTPPath(p) + dirsForPath := []string{sftpPath} + for { + if sftpPath == "/" { + break + } + sftpPath = path.Dir(sftpPath) + dirsForPath = append(dirsForPath, sftpPath) + } + return dirsForPath +} + +// CleanSFTPPath returns a clean sftp path +func CleanSFTPPath(p string) string { + sftpPath := filepath.ToSlash(p) + if !path.IsAbs(p) { + sftpPath = "/" + sftpPath + } + return path.Clean(sftpPath) +} diff --git a/vfs/gcsfs.go b/vfs/gcsfs.go index 3b95af1d..920d5fcf 100644 --- a/vfs/gcsfs.go +++ b/vfs/gcsfs.go @@ -385,7 +385,7 @@ func (GCSFs) IsPermission(err error) bool { // CheckRootPath creates the specified root directory if it does not exists func (fs GCSFs) CheckRootPath(username string, uid int, gid int) bool { // we need a local directory for temporary files - osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir) + osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir, nil) osFs.CheckRootPath(username, uid, gid) return fs.checkIfBucketExists() != nil } diff --git a/vfs/osfs.go b/vfs/osfs.go index 62695c0e..4c3a07b9 100644 --- a/vfs/osfs.go +++ b/vfs/osfs.go @@ -2,13 +2,14 @@ package vfs import ( "fmt" - "io/ioutil" "os" + "path" "path/filepath" "strings" "time" "github.com/drakkan/sftpgo/logger" + "github.com/drakkan/sftpgo/utils" "github.com/eikenb/pipeat" "github.com/rs/xid" ) @@ -20,17 +21,19 @@ const ( // OsFs is a Fs implementation that uses functions provided by the os package. type OsFs struct { - name string - connectionID string - rootDir string + name string + connectionID string + rootDir string + virtualFolders []VirtualFolder } // NewOsFs returns an OsFs object that allows to interact with local Os filesystem -func NewOsFs(connectionID, rootDir string) Fs { +func NewOsFs(connectionID, rootDir string, virtualFolders []VirtualFolder) Fs { return &OsFs{ - name: osFsName, - connectionID: connectionID, - rootDir: rootDir, + name: osFsName, + connectionID: connectionID, + rootDir: rootDir, + virtualFolders: virtualFolders, } } @@ -111,7 +114,16 @@ func (OsFs) Chtimes(name string, atime, mtime time.Time) error { // 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) + f, err := os.Open(dirname) + if err != nil { + return nil, err + } + list, err := f.Readdir(-1) + f.Close() + if err != nil { + return nil, err + } + return list, nil } // IsUploadResumeSupported returns true if upload resume is supported @@ -147,26 +159,32 @@ func (fs OsFs) CheckRootPath(username string, uid int, gid int) bool { SetPathPermissions(fs, fs.rootDir, uid, gid) } } + // create any missing dirs to the defined virtual dirs + for _, v := range fs.virtualFolders { + p := filepath.Clean(filepath.Join(fs.rootDir, v.VirtualPath)) + err = fs.createMissingDirs(p, uid, gid) + if err != nil { + return false + } + } return (err == nil) } // ScanRootDirContents returns the number of files contained in a directory and // their size func (fs OsFs) ScanRootDirContents() (int, int64, error) { - numFiles := 0 - size := int64(0) - isDir, err := IsDirectory(fs, fs.rootDir) - if err == nil && isDir { - err = filepath.Walk(fs.rootDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + numFiles, size, err := fs.getDirSize(fs.rootDir) + for _, v := range fs.virtualFolders { + num, s, err := fs.getDirSize(v.MappedPath) + if err != nil { + if fs.IsNotExist(err) { + fsLog(fs, logger.LevelWarn, "unable to scan contents for not existent mapped path: %#v", v.MappedPath) + continue } - if info != nil && info.Mode().IsRegular() { - size += info.Size() - numFiles++ - } - return err - }) + return numFiles, size, err + } + numFiles += num + size += s } return numFiles, size, err } @@ -181,14 +199,23 @@ func (OsFs) GetAtomicUploadPath(name string) string { // GetRelativePath returns the path for a file relative to the user's home dir. // This is the path as seen by SFTP users func (fs OsFs) GetRelativePath(name string) string { - rel, err := filepath.Rel(fs.rootDir, filepath.Clean(name)) + basePath := fs.rootDir + virtualPath := "/" + for _, v := range fs.virtualFolders { + if strings.HasPrefix(name, v.MappedPath+string(os.PathSeparator)) || + filepath.Clean(name) == v.MappedPath { + basePath = v.MappedPath + virtualPath = v.VirtualPath + } + } + rel, err := filepath.Rel(basePath, filepath.Clean(name)) if err != nil { return "" } if rel == "." || strings.HasPrefix(rel, "..") { rel = "" } - return "/" + filepath.ToSlash(rel) + return path.Join(virtualPath, filepath.ToSlash(rel)) } // Join joins any number of path elements into a single path @@ -201,27 +228,61 @@ func (fs OsFs) ResolvePath(sftpPath string) (string, error) { if !filepath.IsAbs(fs.rootDir) { return "", fmt.Errorf("Invalid root path: %v", fs.rootDir) } - r := filepath.Clean(filepath.Join(fs.rootDir, sftpPath)) + basePath, r := fs.GetFsPaths(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, fs.rootDir) + _, err = fs.findFirstExistingDir(r, basePath) if err != nil { fsLog(fs, logger.LevelWarn, "error resolving not existent path: %#v", err) } return r, err } - err = fs.isSubDir(p, fs.rootDir) + err = fs.isSubDir(p, basePath) if err != nil { fsLog(fs, logger.LevelWarn, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, fs.rootDir, err) } return r, err } +// GetFsPaths returns the base path and filesystem path for the given sftpPath. +// base path is the root dir or matching the virtual folder dir for the sftpPath. +// file path is the filesystem path matching the sftpPath +func (fs *OsFs) GetFsPaths(sftpPath string) (string, string) { + basePath := fs.rootDir + virtualPath, mappedPath := fs.getMappedFolderForPath(sftpPath) + if len(mappedPath) > 0 { + basePath = mappedPath + sftpPath = strings.TrimPrefix(utils.CleanSFTPPath(sftpPath), virtualPath) + } + r := filepath.Clean(filepath.Join(basePath, sftpPath)) + return basePath, r +} + +// returns the path for the mapped folders or an empty string +func (fs *OsFs) getMappedFolderForPath(p string) (virtualPath, mappedPath string) { + if len(fs.virtualFolders) == 0 { + return + } + dirsForPath := utils.GetDirsForSFTPPath(p) + // dirsForPath contains all the dirs for a given path in reverse order + // for example if the path is: /1/2/3/4 it contains: + // [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ] + // so the first match is the one we are interested to + for _, val := range dirsForPath { + for _, v := range fs.virtualFolders { + if val == v.VirtualPath { + return v.VirtualPath, v.MappedPath + } + } + } + return +} + func (fs *OsFs) findNonexistentDirs(path, rootPath string) ([]string, error) { results := []string{} cleanPath := filepath.Clean(path) @@ -279,7 +340,7 @@ 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) + fsLog(fs, logger.LevelWarn, "invalid root path %#v: %v", rootPath, err) return err } if !strings.HasPrefix(sub, parent) { @@ -289,3 +350,39 @@ func (fs *OsFs) isSubDir(sub, rootPath string) error { } return nil } + +func (fs *OsFs) createMissingDirs(filePath string, uid, gid int) error { + dirsToCreate, err := fs.findNonexistentDirs(filePath, fs.rootDir) + if err != nil { + return err + } + last := len(dirsToCreate) - 1 + for i := range dirsToCreate { + d := dirsToCreate[last-i] + if err := os.Mkdir(d, 0777); err != nil { + fsLog(fs, logger.LevelError, "error creating missing dir: %#v", d) + return err + } + SetPathPermissions(fs, d, uid, gid) + } + return nil +} + +func (fs *OsFs) getDirSize(dirname string) (int, int64, error) { + numFiles := 0 + size := int64(0) + isDir, err := IsDirectory(fs, dirname) + if err == nil && isDir { + err = filepath.Walk(dirname, 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 +} diff --git a/vfs/s3fs.go b/vfs/s3fs.go index a223f060..8032c90e 100644 --- a/vfs/s3fs.go +++ b/vfs/s3fs.go @@ -409,7 +409,7 @@ func (S3Fs) IsPermission(err error) bool { // CheckRootPath creates the specified root directory if it does not exists func (fs S3Fs) CheckRootPath(username string, uid int, gid int) bool { // we need a local directory for temporary files - osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir) + osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir, nil) osFs.CheckRootPath(username, uid, gid) return fs.checkIfBucketExists() != nil } diff --git a/vfs/vfs.go b/vfs/vfs.go index eb17ef54..2b8814ac 100644 --- a/vfs/vfs.go +++ b/vfs/vfs.go @@ -43,6 +43,17 @@ type Fs interface { Join(elem ...string) string } +// VirtualFolder defines a mapping between a SFTP/SCP virtual path and a +// filesystem path outside the user home directory. +// The specified paths must be absolute and the virtual path cannot be "/", +// it must be a sub directory. The parent directory for the specified virtual +// path must exist. SFTPGo will try to automatically create any missing +// parent directory for the configured virtual folders at user login. +type VirtualFolder struct { + VirtualPath string `json:"virtual_path"` + MappedPath string `json:"mapped_path"` +} + // IsDirectory checks if a path exists and is a directory func IsDirectory(fs Fs, path string) (bool, error) { fileInfo, err := fs.Stat(path)