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 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)

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 {
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)}
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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
}

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,
"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)
}

View file

@ -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 {

View file

@ -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,

View file

@ -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")

View file

@ -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 {

View file

@ -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 ")

View file

@ -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) {

View file

@ -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

View file

@ -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),

View file

@ -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"
}
]
}
]
```

View file

@ -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':

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)
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)

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 {
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{

View file

@ -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

View file

@ -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")

View file

@ -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 += "/"
}

View file

@ -102,11 +102,11 @@
<textarea class="form-control" id="idSubDirsPermissions" name="sub_dirs_permissions" rows="3"
aria-describedby="subDirsHelpBlock">{{range $dir, $perms := .User.Permissions -}}
{{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}}</textarea>
<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>
</div>
</div>
@ -119,6 +119,19 @@
</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">
<label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
<div class="col-sm-3">

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)