mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 23:20:24 +00:00
add support for virtual folders
directories outside the user home directory can be exposed as virtual folders
This commit is contained in:
parent
382c6fda89
commit
45b9366dd0
27 changed files with 973 additions and 136 deletions
15
README.md
15
README.md
|
@ -18,6 +18,7 @@ Full featured and highly configurable SFTP server
|
||||||
- Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled.
|
- Per user 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)
|
||||||
|
|
|
@ -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)}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 ")
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
23
httpd/web.go
23
httpd/web.go
|
@ -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),
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 += "/"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}}
|
{{$dir}}::{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}
|
||||||
{{- 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}}
|
||||||
|
{{- 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">
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
153
vfs/osfs.go
153
vfs/osfs.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
11
vfs/vfs.go
11
vfs/vfs.go
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue