add support for virtual folders

directories outside the user home directory can be exposed as virtual folders
This commit is contained in:
Nicola Murino 2020-02-23 11:30:26 +01:00
parent 382c6fda89
commit 45b9366dd0
27 changed files with 973 additions and 136 deletions

View file

@ -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 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 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. - 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. - 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. - Automatically terminating idle connections.
- Atomic uploads are configurable. - 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. - `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. - `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. - `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. - `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. - `max_sessions` maximum concurrent sessions. 0 means unlimited.
- `quota_size` maximum size allowed as bytes. 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` - `login_type` string. Can be `publickey`, `password`, `keyboard-interactive` or `no_auth_tryed`
- `error` string. Optional error description - `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. 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 ## Acknowledgements
- [pkg/sftp](https://github.com/pkg/sftp) - [pkg/sftp](https://github.com/pkg/sftp)

View file

@ -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 { func validatePermissions(user *User) error {
if len(user.Permissions) == 0 { if len(user.Permissions) == 0 {
return &ValidationError{err: "please grant some permissions to this user"} 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 { if err := validateFilesystemConfig(user); err != nil {
return err return err
} }
if err := validateVirtualFolders(user); err != nil {
return err
}
if user.Status < 0 || user.Status > 1 { if user.Status < 0 || user.Status > 1 {
return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)} return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)}
} }

View file

@ -19,6 +19,7 @@ const (
"`last_login` bigint(20) NOT NULL, `status` int(11) NOT NULL, `filters` longtext DEFAULT NULL, " + "`last_login` bigint(20) NOT NULL, `status` int(11) NOT NULL, `filters` longtext DEFAULT NULL, " +
"`filesystem` longtext DEFAULT NULL);" "`filesystem` longtext DEFAULT NULL);"
mysqlSchemaTableSQL = "CREATE TABLE `schema_version` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT 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 // MySQLProvider auth provider for MySQL/MariaDB database
@ -143,5 +144,36 @@ func (p MySQLProvider) initializeDatabase() error {
} }
func (p MySQLProvider) migrateDatabase() 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()
} }

View file

@ -17,6 +17,7 @@ const (
"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
"filesystem" text NULL);` "filesystem" text NULL);`
pgsqlSchemaTableSQL = `CREATE TABLE "schema_version" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT 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 // PGSQLProvider auth provider for PostgreSQL database
@ -141,5 +142,36 @@ func (p PGSQLProvider) initializeDatabase() error {
} }
func (p PGSQLProvider) migrateDatabase() 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()
} }

View file

@ -9,10 +9,11 @@ import (
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
) )
const ( const (
sqlDatabaseVersion = 1 sqlDatabaseVersion = 2
initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);" initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);"
) )
@ -171,9 +172,13 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
if err != nil { if err != nil {
return err 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, _, 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)) string(fsConfig), string(virtualFolders))
return err return err
} }
@ -205,9 +210,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
if err != nil { if err != nil {
return err 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, _, 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, 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 return err
} }
@ -281,6 +290,25 @@ func sqlCommonGetUsers(limit int, offset int, order string, username string, dbH
return users, err 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) { func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
var user User var user User
var permissions sql.NullString var permissions sql.NullString
@ -288,16 +316,19 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
var publicKey sql.NullString var publicKey sql.NullString
var filters sql.NullString var filters sql.NullString
var fsConfig sql.NullString var fsConfig sql.NullString
var virtualFolders sql.NullString
var err error var err error
if row != nil { if row != nil {
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, 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.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 { } else {
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, 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.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 != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -308,6 +339,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
if password.Valid { if password.Valid {
user.Password = password.String 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 { if publicKey.Valid {
var list []string var list []string
err = json.Unmarshal([]byte(publicKey.String), &list) err = json.Unmarshal([]byte(publicKey.String), &list)
@ -316,18 +350,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
} }
} }
if permissions.Valid { if permissions.Valid {
perms := make(map[string][]string) err = updateUserPermissionsFromDb(&user, permissions.String)
err = json.Unmarshal([]byte(permissions.String), &perms) if err != nil {
if err == nil { return user, err
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
}
} }
} }
if filters.Valid { if filters.Valid {
@ -336,11 +361,6 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
if err == nil { if err == nil {
user.Filters = userFilters user.Filters = userFilters
} }
} else {
user.Filters = UserFilters{
AllowedIP: []string{},
DeniedIP: []string{},
}
} }
if fsConfig.Valid { if fsConfig.Valid {
var fs Filesystem var fs Filesystem
@ -348,26 +368,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
if err == nil { if err == nil {
user.FsConfig = fs user.FsConfig = fs
} }
} else { }
user.FsConfig = Filesystem{ if virtualFolders.Valid {
Provider: 0, var list []vfs.VirtualFolder
err = json.Unmarshal([]byte(virtualFolders.String), &list)
if err == nil {
user.VirtualFolders = list
} }
} }
return user, err 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) { func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) {
var result schemaVersion var result schemaVersion
q := getDatabaseVersionQuery() q := getDatabaseVersionQuery()
@ -382,7 +393,7 @@ func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) {
return result, err return result, err
} }
func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error { func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error {
q := getUpdateDBVersionQuery() q := getUpdateDBVersionQuery()
stmt, err := dbHandle.Prepare(q) stmt, err := dbHandle.Prepare(q)
if err != nil { if err != nil {
@ -390,7 +401,18 @@ func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error {
return err return err
} }
defer stmt.Close() 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 return err
} }

View file

@ -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, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
"filesystem" text NULL);` "filesystem" text NULL);`
sqliteSchemaTableSQL = `CREATE TABLE "schema_version" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT 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 // SQLiteProvider auth provider for SQLite database
@ -119,5 +120,26 @@ func (p SQLiteProvider) initializeDatabase() error {
} }
func (p SQLiteProvider) migrateDatabase() 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)
} }

View file

@ -4,7 +4,8 @@ import "fmt"
const ( const (
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," + 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 { func getSQLPlaceholders() []string {
@ -61,19 +62,20 @@ func getQuotaQuery() string {
func getAddUserQuery() 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, 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, used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
filesystem) filesystem,virtual_folders)
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], 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[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], 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 { 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, 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 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], 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[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 { func getDeleteUserQuery() string {

View file

@ -4,9 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"os"
"path" "path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
@ -90,6 +92,8 @@ type User struct {
PublicKeys []string `json:"public_keys,omitempty"` PublicKeys []string `json:"public_keys,omitempty"`
// The user cannot upload or download files outside this directory. Must be an absolute path // The user cannot upload or download files outside this directory. Must be an absolute path
HomeDir string `json:"home_dir"` 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 // If sftpgo runs as root system user then the created files and directories will be assigned to this system UID
UID int `json:"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 // 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() config.CredentialFile = u.getGCSCredentialsFilePath()
return vfs.NewGCSFs(connectionID, u.GetHomeDir(), config) 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. // GetPermissionsForPath returns the permissions for the given path.
@ -144,19 +148,7 @@ func (u *User) GetPermissionsForPath(p string) []string {
// fallback permissions // fallback permissions
permissions = perms permissions = perms
} }
sftpPath := filepath.ToSlash(p) dirsForPath := utils.GetDirsForSFTPPath(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 contains all the dirs for a given path in reverse order // dirsForPath contains all the dirs for a given path in reverse order
// for example if the path is: /1/2/3/4 it contains: // for example if the path is: /1/2/3/4 it contains:
// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ] // [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ]
@ -170,6 +162,40 @@ func (u *User) GetPermissionsForPath(p string) []string {
return permissions 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 // HasPerm returns true if the user has the given permission or any permission
func (u *User) HasPerm(permission, path string) bool { func (u *User) HasPerm(permission, path string) bool {
perms := u.GetPermissionsForPath(path) perms := u.GetPermissionsForPath(path)
@ -259,6 +285,11 @@ func (u *User) GetFsConfigAsJSON() ([]byte, error) {
return json.Marshal(u.FsConfig) 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 // GetUID returns a validate uid, suitable for use with os.Chown
func (u *User) GetUID() int { func (u *User) GetUID() int {
if u.UID <= 0 || u.UID > 65535 { if u.UID <= 0 || u.UID > 65535 {
@ -417,6 +448,8 @@ func (u User) GetDeniedIPAsString() string {
func (u *User) getACopy() User { func (u *User) getACopy() User {
pubKeys := make([]string, len(u.PublicKeys)) pubKeys := make([]string, len(u.PublicKeys))
copy(pubKeys, u.PublicKeys) copy(pubKeys, u.PublicKeys)
virtualFolders := make([]vfs.VirtualFolder, len(u.VirtualFolders))
copy(virtualFolders, u.VirtualFolders)
permissions := make(map[string][]string) permissions := make(map[string][]string)
for k, v := range u.Permissions { for k, v := range u.Permissions {
perms := make([]string, len(v)) perms := make([]string, len(v))
@ -456,6 +489,7 @@ func (u *User) getACopy() User {
Password: u.Password, Password: u.Password,
PublicKeys: pubKeys, PublicKeys: pubKeys,
HomeDir: u.HomeDir, HomeDir: u.HomeDir,
VirtualFolders: virtualFolders,
UID: u.UID, UID: u.UID,
GID: u.GID, GID: u.GID,
MaxSessions: u.MaxSessions, MaxSessions: u.MaxSessions,

View file

@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -422,10 +423,32 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
if err := compareUserFsConfig(expected, actual); err != nil { if err := compareUserFsConfig(expected, actual); err != nil {
return err return err
} }
if err := compareUserVirtualFolders(expected, actual); err != nil {
return err
}
return compareEqualsUserFields(expected, actual) 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 { func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User) error {
if expected.FsConfig.Provider != actual.FsConfig.Provider { if expected.FsConfig.Provider != actual.FsConfig.Provider {
return errors.New("Fs provider mismatch") return errors.New("Fs provider mismatch")

View file

@ -104,6 +104,7 @@ func (c Conf) Initialize(configDir string) error {
Handler: router, Handler: router,
ReadTimeout: 60 * time.Second, ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 16, // 64KB MaxHeaderBytes: 1 << 16, // 64KB
} }
if len(certificateFile) > 0 && len(certificateKeyFile) > 0 { if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {

View file

@ -33,6 +33,7 @@ import (
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/sftpd"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
) )
const ( 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) { func TestUserPublicKey(t *testing.T) {
u := getTestUser() u := getTestUser()
invalidPubKey := "invalid" invalidPubKey := "invalid"
@ -424,6 +551,15 @@ func TestUpdateUser(t *testing.T) {
user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword} user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword}
user.UploadBandwidth = 1024 user.UploadBandwidth = 1024
user.DownloadBandwidth = 512 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) user, _, err = httpd.UpdateUser(user, http.StatusOK)
if err != nil { if err != nil {
t.Errorf("unable to update user: %v", err) t.Errorf("unable to update user: %v", err)
@ -1560,6 +1696,7 @@ func TestWebUserAddMock(t *testing.T) {
user.UploadBandwidth = 32 user.UploadBandwidth = 32
user.DownloadBandwidth = 64 user.DownloadBandwidth = 64
user.UID = 1000 user.UID = 1000
mappedDir := filepath.Join(os.TempDir(), "mapped")
form := make(url.Values) form := make(url.Values)
form.Set("username", user.Username) form.Set("username", user.Username)
form.Set("home_dir", user.HomeDir) form.Set("home_dir", user.HomeDir)
@ -1567,7 +1704,8 @@ func TestWebUserAddMock(t *testing.T) {
form.Set("status", strconv.Itoa(user.Status)) form.Set("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "") form.Set("expiration_date", "")
form.Set("permissions", "*") 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, "", "") b, contentType, _ := getMultipartFormData(form, "", "")
// test invalid url escape // test invalid url escape
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b) 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") t.Error("permssions for /subdir does not match")
} }
} else { } 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) req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
rr = executeRequest(req) rr = executeRequest(req)
@ -1728,7 +1875,7 @@ func TestWebUserUpdateMock(t *testing.T) {
form.Set("upload_bandwidth", "0") form.Set("upload_bandwidth", "0")
form.Set("download_bandwidth", "0") form.Set("download_bandwidth", "0")
form.Set("permissions", "*") 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("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "2020-01-01 00:00:00") form.Set("expiration_date", "2020-01-01 00:00:00")
form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ") form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")

View file

@ -17,6 +17,7 @@ import (
"github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/sftpd"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
"github.com/go-chi/chi" "github.com/go-chi/chi"
) )
@ -103,6 +104,23 @@ func TestCheckUser(t *testing.T) {
if err == nil { if err == nil {
t.Errorf("Fs providers are not equal") 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) { func TestCompareUserFilters(t *testing.T) {

View file

@ -2,7 +2,7 @@ openapi: 3.0.1
info: info:
title: SFTPGo title: SFTPGo
description: 'SFTPGo REST API' description: 'SFTPGo REST API'
version: 1.8.1 version: 1.8.2
servers: servers:
- url: /api/v1 - url: /api/v1
@ -1036,6 +1036,17 @@ components:
gcsconfig: gcsconfig:
$ref: '#/components/schemas/GCSConfig' $ref: '#/components/schemas/GCSConfig'
description: Storage filesystem details 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: User:
type: object type: object
properties: properties:
@ -1071,6 +1082,12 @@ components:
home_dir: home_dir:
type: string 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 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: uid:
type: integer type: integer
format: int32 format: int32

View file

@ -15,6 +15,7 @@ import (
"github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/sftpd"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
) )
const ( const (
@ -186,13 +187,30 @@ func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error s
renderTemplate(w, templateUser, data) 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 { func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
permissions := make(map[string][]string) permissions := make(map[string][]string)
permissions["/"] = r.Form["permissions"] permissions["/"] = r.Form["permissions"]
subDirsPermsValue := r.Form.Get("sub_dirs_permissions") subDirsPermsValue := r.Form.Get("sub_dirs_permissions")
for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") { for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") {
if strings.ContainsRune(cleaned, ':') { if strings.Contains(cleaned, "::") {
dirPerms := strings.Split(cleaned, ":") dirPerms := strings.Split(cleaned, "::")
if len(dirPerms) > 1 { if len(dirPerms) > 1 {
dir := dirPerms[0] dir := dirPerms[0]
dir = strings.TrimSpace(dir) dir = strings.TrimSpace(dir)
@ -335,6 +353,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
Password: r.Form.Get("password"), Password: r.Form.Get("password"),
PublicKeys: publicKeys, PublicKeys: publicKeys,
HomeDir: r.Form.Get("home_dir"), HomeDir: r.Form.Get("home_dir"),
VirtualFolders: getVirtualFoldersFromPostFields(r),
UID: uid, UID: uid,
GID: gid, GID: gid,
Permissions: getUserPermissionsFromPostFields(r), Permissions: getUserPermissionsFromPostFields(r),

View file

@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
Command: 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: Output:
@ -115,7 +115,7 @@ Output:
Command: 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: Output:
@ -175,7 +175,17 @@ Output:
"upload_bandwidth": 90, "upload_bandwidth": 90,
"used_quota_files": 0, "used_quota_files": 0,
"used_quota_size": 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, "upload_bandwidth": 90,
"used_quota_files": 0, "used_quota_files": 0,
"used_quota_size": 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"
}
]
} }
] ]
``` ```

View file

@ -76,7 +76,7 @@ class SFTPGoApiRequests:
status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', 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_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='', 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, user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid,
'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files, 'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth, 'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
@ -92,6 +92,8 @@ class SFTPGoApiRequests:
user.update({'home_dir':home_dir}) user.update({'home_dir':home_dir})
if permissions: if permissions:
user.update({'permissions':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: if allowed_ip or denied_ip or denied_login_methods:
user.update({'filters':self.buildFilters(allowed_ip, denied_ip, 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, 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)}) gcs_automatic_credentials)})
return user 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): def buildPermissions(self, root_perms, subdirs_perms):
permissions = {} permissions = {}
if root_perms: if root_perms:
permissions.update({'/':root_perms}) permissions.update({'/':root_perms})
for p in subdirs_perms: for p in subdirs_perms:
if ':' in p: if '::' in p:
directory = None directory = None
values = [] values = []
for value in p.split(':'): for value in p.split('::'):
if directory is None: if directory is None:
directory = value directory = value
else: else:
@ -172,12 +188,12 @@ class SFTPGoApiRequests:
subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='', 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='', 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', 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, 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, 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, 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, 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) r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
self.printResponse(r) self.printResponse(r)
@ -186,12 +202,12 @@ class SFTPGoApiRequests:
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', 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_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='', 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, 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, 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, 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, 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) r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify)
self.printResponse(r) self.printResponse(r)
@ -435,7 +451,9 @@ def addCommonUserArguments(parser):
parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[], parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[],
choices=['', 'publickey', 'password', 'keyboard-interactive'], help='Default: %(default)s') choices=['', 'publickey', 'password', 'keyboard-interactive'], help='Default: %(default)s')
parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' 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, parser.add_argument('-U', '--upload-bandwidth', type=int, default=0,
help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s') help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
parser.add_argument('-D', '--download-bandwidth', type=int, default=0, 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.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.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.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': elif args.command == 'update-user':
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, 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.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.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_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.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': elif args.command == 'delete-user':
api.deleteUser(args.id) api.deleteUser(args.id)
elif args.command == 'get-users': elif args.command == 'get-users':

View file

@ -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) c.Log(logger.LevelDebug, logSender, "requested list file for dir: %#v", p)
files, err := c.fs.ReadDir(p) files, err := c.fs.ReadDir(p)
if err != nil { if err != nil {
c.Log(logger.LevelWarn, logSender, "error listing directory: %+v", err) c.Log(logger.LevelWarn, logSender, "error listing directory: %+v", err)
return nil, vfs.GetSFTPError(c.fs, err) return nil, vfs.GetSFTPError(c.fs, err)
} }
return listerAt(files), nil return listerAt(c.User.AddVirtualDirs(files, request.Filepath)), nil
case "Stat": case "Stat":
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) { if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) {
return nil, sftp.ErrSSHFxPermissionDenied 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") c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed")
return sftp.ErrSSHFxPermissionDenied 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)) { if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) {
return sftp.ErrSSHFxPermissionDenied 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") c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed")
return sftp.ErrSSHFxPermissionDenied 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)) { if !c.User.HasPerm(dataprovider.PermDelete, path.Dir(request.Filepath)) {
return sftp.ErrSSHFxPermissionDenied 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") c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed")
return sftp.ErrSSHFxPermissionDenied 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)) { if !c.User.HasPerm(dataprovider.PermCreateSymlinks, path.Dir(request.Target)) {
return sftp.ErrSSHFxPermissionDenied 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)) { if !c.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(request.Filepath)) {
return sftp.ErrSSHFxPermissionDenied 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 { if err := c.fs.Mkdir(dirPath); err != nil {
c.Log(logger.LevelWarn, logSender, "error creating missing dir: %#v error: %+v", dirPath, err) c.Log(logger.LevelWarn, logSender, "error creating missing dir: %#v error: %+v", dirPath, err)
return vfs.GetSFTPError(c.fs, err) return vfs.GetSFTPError(c.fs, err)

View file

@ -112,7 +112,7 @@ func (fs MockOsFs) Rename(source, target string) error {
func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir string) vfs.Fs { func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir string) vfs.Fs {
return &MockOsFs{ return &MockOsFs{
Fs: vfs.NewOsFs(connectionID, rootDir), Fs: vfs.NewOsFs(connectionID, rootDir, nil),
err: err, err: err,
statErr: statErr, statErr: statErr,
isAtomicUploadSupported: atomicUpload, isAtomicUploadSupported: atomicUpload,
@ -388,7 +388,7 @@ func TestUploadFiles(t *testing.T) {
oldUploadMode := uploadMode oldUploadMode := uploadMode
uploadMode = uploadModeAtomic uploadMode = uploadModeAtomic
c := Connection{ c := Connection{
fs: vfs.NewOsFs("123", os.TempDir()), fs: vfs.NewOsFs("123", os.TempDir(), nil),
} }
var flags sftp.FileOpenFlags var flags sftp.FileOpenFlags
flags.Write = true flags.Write = true
@ -477,7 +477,7 @@ func TestSFTPCmdTargetPath(t *testing.T) {
func TestGetSFTPErrorFromOSError(t *testing.T) { func TestGetSFTPErrorFromOSError(t *testing.T) {
err := os.ErrNotExist err := os.ErrNotExist
fs := vfs.NewOsFs("", os.TempDir()) fs := vfs.NewOsFs("", os.TempDir(), nil)
err = vfs.GetSFTPError(fs, err) err = vfs.GetSFTPError(fs, err)
if err != sftp.ErrSSHFxNoSuchFile { if err != sftp.ErrSSHFxNoSuchFile {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
@ -1287,7 +1287,7 @@ func TestSCPRecursiveDownloadErrors(t *testing.T) {
connection := Connection{ connection := Connection{
channel: &mockSSHChannel, channel: &mockSSHChannel,
netConn: client, netConn: client,
fs: vfs.NewOsFs("123", os.TempDir()), fs: vfs.NewOsFs("123", os.TempDir(), nil),
} }
scpCommand := scpCommand{ scpCommand := scpCommand{
sshCommand: sshCommand{ sshCommand: sshCommand{

View file

@ -652,7 +652,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) {
c.sendErrorMessage(err.Error()) c.sendErrorMessage(err.Error())
return size, name, err return size, name, err
} }
parts := strings.Split(command, " ") parts := strings.SplitN(command, " ", 3)
if len(parts) == 3 { if len(parts) == 3 {
size, err = strconv.ParseInt(parts[1], 10, 64) size, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil { if err != nil {
@ -668,7 +668,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) {
return size, name, err return size, name, err
} }
} else { } 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.connection.Log(logger.LevelWarn, logSenderSCP, "error: %v", err)
c.sendErrorMessage(err.Error()) c.sendErrorMessage(err.Error())
return size, name, err return size, name, err

View file

@ -1988,6 +1988,68 @@ func TestBandwidthAndConnections(t *testing.T) {
os.RemoveAll(user.GetHomeDir()) 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) { func TestMissingFile(t *testing.T) {
usePubKey := false usePubKey := false
u := getTestUser(usePubKey) u := getTestUser(usePubKey)
@ -3116,7 +3178,7 @@ func TestRootDirCommands(t *testing.T) {
func TestRelativePaths(t *testing.T) { func TestRelativePaths(t *testing.T) {
user := getTestUser(true) user := getTestUser(true)
var path, rel string 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(), "/") + "/" keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/"
s3config := vfs.S3FsConfig{ s3config := vfs.S3FsConfig{
KeyPrefix: keyPrefix, KeyPrefix: keyPrefix,
@ -3187,7 +3249,7 @@ func TestResolvePaths(t *testing.T) {
user := getTestUser(true) user := getTestUser(true)
var path, resolved string var path, resolved string
var err error var err error
filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())} filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)}
keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/" keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/"
s3config := vfs.S3FsConfig{ s3config := vfs.S3FsConfig{
KeyPrefix: keyPrefix, KeyPrefix: keyPrefix,
@ -3243,6 +3305,80 @@ func TestResolvePaths(t *testing.T) {
os.RemoveAll(user.GetHomeDir()) 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) { func TestUserPerms(t *testing.T) {
user := getTestUser(true) user := getTestUser(true)
user.Permissions = make(map[string][]string) 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) { func TestSCPPermsSubDirs(t *testing.T) {
if len(scpPath) == 0 { if len(scpPath) == 0 {
t.Skip("scp command not found, unable to execute this test") t.Skip("scp command not found, unable to execute this test")

View file

@ -11,8 +11,6 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -360,11 +358,7 @@ func (c *sshCommand) getDestPath() string {
} }
destPath := strings.Trim(c.args[len(c.args)-1], "'") destPath := strings.Trim(c.args[len(c.args)-1], "'")
destPath = strings.Trim(destPath, "\"") destPath = strings.Trim(destPath, "\"")
destPath = filepath.ToSlash(destPath) result := utils.CleanSFTPPath(destPath)
if !path.IsAbs(destPath) {
destPath = "/" + destPath
}
result := path.Clean(destPath)
if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") { if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") {
result += "/" result += "/"
} }

View file

@ -102,11 +102,11 @@
<textarea class="form-control" id="idSubDirsPermissions" name="sub_dirs_permissions" rows="3" <textarea class="form-control" id="idSubDirsPermissions" name="sub_dirs_permissions" rows="3"
aria-describedby="subDirsHelpBlock">{{range $dir, $perms := .User.Permissions -}} aria-describedby="subDirsHelpBlock">{{range $dir, $perms := .User.Permissions -}}
{{if ne $dir "/" -}} {{if ne $dir "/" -}}
{{$dir}}:{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}&#10; {{$dir}}::{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}&#10;
{{- end}} {{- end}}
{{- end}}</textarea> {{- end}}</textarea>
<small id="subDirsHelpBlock" class="form-text text-muted"> <small id="subDirsHelpBlock" class="form-text text-muted">
One directory per line as dir:perms, for example /somedir:list,download One directory per line as dir::perms, for example /somedir::list,download
</small> </small>
</div> </div>
</div> </div>
@ -119,6 +119,19 @@
</div> </div>
</div> </div>
<div class="form-group row">
<label for="idVirtualFolders" class="col-sm-2 col-form-label">Virtual folders</label>
<div class="col-sm-10">
<textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
aria-describedby="vfHelpBlock">{{range $index, $mapping := .User.VirtualFolders -}}
{{$mapping.VirtualPath}}::{{$mapping.MappedPath}}&#10;
{{- end}}</textarea>
<small id="vfHelpBlock" class="form-text text-muted">
One mapping per line as vpath::path, for example /vdir::/home/adir or /vdir::C:\adir, ignored for non local filesystems
</small>
</div>
</div>
<div class="form-group row"> <div class="form-group row">
<label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label> <label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
<div class="col-sm-3"> <div class="col-sm-3">

View file

@ -17,6 +17,8 @@ import (
"io/ioutil" "io/ioutil"
"net" "net"
"os" "os"
"path"
"path/filepath"
"strings" "strings"
"time" "time"
@ -248,3 +250,28 @@ func GenerateECDSAKeys(file string) error {
} }
return ioutil.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0600) 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)
}

View file

@ -385,7 +385,7 @@ func (GCSFs) IsPermission(err error) bool {
// CheckRootPath creates the specified root directory if it does not exists // CheckRootPath creates the specified root directory if it does not exists
func (fs GCSFs) CheckRootPath(username string, uid int, gid int) bool { func (fs GCSFs) CheckRootPath(username string, uid int, gid int) bool {
// we need a local directory for temporary files // 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) osFs.CheckRootPath(username, uid, gid)
return fs.checkIfBucketExists() != nil return fs.checkIfBucketExists() != nil
} }

View file

@ -2,13 +2,14 @@ package vfs
import ( import (
"fmt" "fmt"
"io/ioutil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"github.com/eikenb/pipeat" "github.com/eikenb/pipeat"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -20,17 +21,19 @@ const (
// OsFs is a Fs implementation that uses functions provided by the os package. // OsFs is a Fs implementation that uses functions provided by the os package.
type OsFs struct { type OsFs struct {
name string name string
connectionID string connectionID string
rootDir string rootDir string
virtualFolders []VirtualFolder
} }
// NewOsFs returns an OsFs object that allows to interact with local Os filesystem // 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{ return &OsFs{
name: osFsName, name: osFsName,
connectionID: connectionID, connectionID: connectionID,
rootDir: rootDir, 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 // ReadDir reads the directory named by dirname and returns
// a list of directory entries. // a list of directory entries.
func (OsFs) ReadDir(dirname string) ([]os.FileInfo, error) { 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 // 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) 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) return (err == nil)
} }
// ScanRootDirContents returns the number of files contained in a directory and // ScanRootDirContents returns the number of files contained in a directory and
// their size // their size
func (fs OsFs) ScanRootDirContents() (int, int64, error) { func (fs OsFs) ScanRootDirContents() (int, int64, error) {
numFiles := 0 numFiles, size, err := fs.getDirSize(fs.rootDir)
size := int64(0) for _, v := range fs.virtualFolders {
isDir, err := IsDirectory(fs, fs.rootDir) num, s, err := fs.getDirSize(v.MappedPath)
if err == nil && isDir { if err != nil {
err = filepath.Walk(fs.rootDir, func(path string, info os.FileInfo, err error) error { if fs.IsNotExist(err) {
if err != nil { fsLog(fs, logger.LevelWarn, "unable to scan contents for not existent mapped path: %#v", v.MappedPath)
return err continue
} }
if info != nil && info.Mode().IsRegular() { return numFiles, size, err
size += info.Size() }
numFiles++ numFiles += num
} size += s
return err
})
} }
return numFiles, size, err 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. // GetRelativePath returns the path for a file relative to the user's home dir.
// This is the path as seen by SFTP users // This is the path as seen by SFTP users
func (fs OsFs) GetRelativePath(name string) string { 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 { if err != nil {
return "" return ""
} }
if rel == "." || strings.HasPrefix(rel, "..") { if rel == "." || strings.HasPrefix(rel, "..") {
rel = "" rel = ""
} }
return "/" + filepath.ToSlash(rel) return path.Join(virtualPath, filepath.ToSlash(rel))
} }
// Join joins any number of path elements into a single path // 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) { if !filepath.IsAbs(fs.rootDir) {
return "", fmt.Errorf("Invalid root path: %v", 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) p, err := filepath.EvalSymlinks(r)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return "", err return "", err
} else if os.IsNotExist(err) { } else if os.IsNotExist(err) {
// The requested path doesn't exist, so at this point we need to iterate up the // 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. // 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 { if err != nil {
fsLog(fs, logger.LevelWarn, "error resolving not existent path: %#v", err) fsLog(fs, logger.LevelWarn, "error resolving not existent path: %#v", err)
} }
return r, err return r, err
} }
err = fs.isSubDir(p, fs.rootDir) err = fs.isSubDir(p, basePath)
if err != nil { if err != nil {
fsLog(fs, logger.LevelWarn, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, fs.rootDir, err) fsLog(fs, logger.LevelWarn, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, fs.rootDir, err)
} }
return r, 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) { func (fs *OsFs) findNonexistentDirs(path, rootPath string) ([]string, error) {
results := []string{} results := []string{}
cleanPath := filepath.Clean(path) 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 // rootPath must exist and it is already a validated absolute path
parent, err := filepath.EvalSymlinks(rootPath) parent, err := filepath.EvalSymlinks(rootPath)
if err != nil { 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 return err
} }
if !strings.HasPrefix(sub, parent) { if !strings.HasPrefix(sub, parent) {
@ -289,3 +350,39 @@ func (fs *OsFs) isSubDir(sub, rootPath string) error {
} }
return nil 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
}

View file

@ -409,7 +409,7 @@ func (S3Fs) IsPermission(err error) bool {
// CheckRootPath creates the specified root directory if it does not exists // CheckRootPath creates the specified root directory if it does not exists
func (fs S3Fs) CheckRootPath(username string, uid int, gid int) bool { func (fs S3Fs) CheckRootPath(username string, uid int, gid int) bool {
// we need a local directory for temporary files // 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) osFs.CheckRootPath(username, uid, gid)
return fs.checkIfBucketExists() != nil return fs.checkIfBucketExists() != nil
} }

View file

@ -43,6 +43,17 @@ type Fs interface {
Join(elem ...string) string 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 // IsDirectory checks if a path exists and is a directory
func IsDirectory(fs Fs, path string) (bool, error) { func IsDirectory(fs Fs, path string) (bool, error) {
fileInfo, err := fs.Stat(path) fileInfo, err := fs.Stat(path)