add support for atomic upload
Atomic uploads are now configurable. The default upload mode remains non atomic
This commit is contained in:
parent
d2361da570
commit
80b9c40489
12 changed files with 319 additions and 94 deletions
|
@ -18,6 +18,7 @@ Full featured and highly configurable SFTP server software
|
||||||
- REST API for users and quota management and real time reports for the active connections with possibility of forcibly closing a connection
|
- REST API for users and quota management and real time reports for the active connections with possibility of forcibly closing a connection
|
||||||
- Log files are accurate and they are saved in the easily parsable JSON format
|
- Log files are accurate and they are saved in the easily parsable JSON format
|
||||||
- Automatically terminating idle connections
|
- Automatically terminating idle connections
|
||||||
|
- Atomic uploads are supported
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
|
@ -73,6 +74,7 @@ The `sftpgo.conf` configuration file contains the following sections:
|
||||||
- `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts are unlimited. If set to zero, the number of attempts are limited to 6.
|
- `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts are unlimited. If set to zero, the number of attempts are limited to 6.
|
||||||
- `umask`, string. Umask for the new files and directories. This setting has no effect on Windows. Default: "0022"
|
- `umask`, string. Umask for the new files and directories. This setting has no effect on Windows. Default: "0022"
|
||||||
- `banner`, string. Identification string used by the server. Default "SFTPGo"
|
- `banner`, string. Identification string used by the server. Default "SFTPGo"
|
||||||
|
- `upload_mode` int. 0 means standard, the files are uploaded directly to the requested path. 1 means atomic: the files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoid problems such as a web server that serves partial files when the files are being uploaded
|
||||||
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions
|
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions
|
||||||
- `execute_on`, list of strings. Valid values are `download`, `upload`, `delete`, `rename`. On folder deletion a `delete` notification will be sent for each deleted file. Leave empty to disable actions.
|
- `execute_on`, list of strings. Valid values are `download`, `upload`, `delete`, `rename`. On folder deletion a `delete` notification will be sent for each deleted file. Leave empty to disable actions.
|
||||||
- `command`, string. Absolute path to the command to execute. Leave empty to disable. The command is invoked with the following arguments:
|
- `command`, string. Absolute path to the command to execute. Leave empty to disable. The command is invoked with the following arguments:
|
||||||
|
@ -193,7 +195,7 @@ REST API is designed to run on localhost or on a trusted network, if you need ht
|
||||||
|
|
||||||
The OpenAPI 3 schema for the exposed API can be found inside the source tree: [openapi.yaml](https://github.com/drakkan/sftpgo/tree/master/api/schema/openapi.yaml "OpenAPI 3 specs").
|
The OpenAPI 3 schema for the exposed API can be found inside the source tree: [openapi.yaml](https://github.com/drakkan/sftpgo/tree/master/api/schema/openapi.yaml "OpenAPI 3 specs").
|
||||||
|
|
||||||
A sample CLI client for the REST API can be found inside the source tree: [scripts](https://github.com/drakkan/sftpgo/tree/master/scripts "scripts") directory.
|
A sample CLI client for the REST API can be found inside the source tree [scripts](https://github.com/drakkan/sftpgo/tree/master/scripts "scripts") directory.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -41,6 +42,7 @@ func init() {
|
||||||
IdleTimeout: 15,
|
IdleTimeout: 15,
|
||||||
MaxAuthTries: 0,
|
MaxAuthTries: 0,
|
||||||
Umask: "0022",
|
Umask: "0022",
|
||||||
|
UploadMode: 0,
|
||||||
Actions: sftpd.Actions{
|
Actions: sftpd.Actions{
|
||||||
ExecuteOn: []string{},
|
ExecuteOn: []string{},
|
||||||
Command: "",
|
Command: "",
|
||||||
|
@ -102,6 +104,13 @@ func LoadConfig(configPath string) error {
|
||||||
if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
|
if strings.TrimSpace(globalConf.SFTPD.Banner) == "" {
|
||||||
globalConf.SFTPD.Banner = defaultBanner
|
globalConf.SFTPD.Banner = defaultBanner
|
||||||
}
|
}
|
||||||
|
if globalConf.SFTPD.UploadMode < 0 || globalConf.SFTPD.UploadMode > 1 {
|
||||||
|
err = fmt.Errorf("Invalid upload_mode 0 and 1 are supported, configured: %v reset upload_mode to 0",
|
||||||
|
globalConf.SFTPD.UploadMode)
|
||||||
|
globalConf.SFTPD.UploadMode = 0
|
||||||
|
logger.Warn(logSender, "Configuration error: %v", err)
|
||||||
|
logger.WarnToConsole("Configuration error: %v", err)
|
||||||
|
}
|
||||||
logger.Debug(logSender, "config loaded: %+v", globalConf)
|
logger.Debug(logSender, "config loaded: %+v", globalConf)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,3 +69,24 @@ func TestEmptyBanner(t *testing.T) {
|
||||||
}
|
}
|
||||||
os.Remove(configFilePath)
|
os.Remove(configFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInvalidUploadMode(t *testing.T) {
|
||||||
|
configDir := ".."
|
||||||
|
confName := "temp.conf"
|
||||||
|
configFilePath := filepath.Join(configDir, confName)
|
||||||
|
config.LoadConfig(configFilePath)
|
||||||
|
sftpdConf := config.GetSFTPDConfig()
|
||||||
|
sftpdConf.UploadMode = 10
|
||||||
|
c := make(map[string]sftpd.Configuration)
|
||||||
|
c["sftpd"] = sftpdConf
|
||||||
|
jsonConf, _ := json.Marshal(c)
|
||||||
|
err := ioutil.WriteFile(configFilePath, jsonConf, 0666)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error saving temporary configuration")
|
||||||
|
}
|
||||||
|
err = config.LoadConfig(configFilePath)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Loading configuration with invalid upload_mode must fail")
|
||||||
|
}
|
||||||
|
os.Remove(configFilePath)
|
||||||
|
}
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -12,6 +12,7 @@ require (
|
||||||
github.com/lib/pq v1.1.1
|
github.com/lib/pq v1.1.1
|
||||||
github.com/mattn/go-sqlite3 v1.10.0
|
github.com/mattn/go-sqlite3 v1.10.0
|
||||||
github.com/pkg/sftp v1.10.0
|
github.com/pkg/sftp v1.10.0
|
||||||
|
github.com/rs/xid v1.2.1
|
||||||
github.com/rs/zerolog v1.14.3
|
github.com/rs/zerolog v1.14.3
|
||||||
github.com/stretchr/testify v1.3.0 // indirect
|
github.com/stretchr/testify v1.3.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
|
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
|
||||||
|
|
1
go.sum
1
go.sum
|
@ -23,6 +23,7 @@ github.com/pkg/sftp v1.10.0 h1:DGA1KlA9esU6WcicH+P8PxFZOl15O6GYtab1cIJdOlE=
|
||||||
github.com/pkg/sftp v1.10.0/go.mod h1:NxmoDg/QLVWluQDUYG7XBZTLUpKeFa8e3aMf1BfjyHk=
|
github.com/pkg/sftp v1.10.0/go.mod h1:NxmoDg/QLVWluQDUYG7XBZTLUpKeFa8e3aMf1BfjyHk=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
|
||||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
github.com/rs/zerolog v1.14.3 h1:4EGfSkR2hJDB0s3oFfrlPqjU1e4WLncergLil3nEKW0=
|
github.com/rs/zerolog v1.14.3 h1:4EGfSkR2hJDB0s3oFfrlPqjU1e4WLncergLil3nEKW0=
|
||||||
github.com/rs/zerolog v1.14.3/go.mod h1:3WXPzbXEEliJ+a6UFE4vhIxV8qR1EML6ngzP9ug4eYg=
|
github.com/rs/zerolog v1.14.3/go.mod h1:3WXPzbXEEliJ+a6UFE4vhIxV8qR1EML6ngzP9ug4eYg=
|
||||||
|
|
191
sftpd/handler.go
191
sftpd/handler.go
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/drakkan/sftpgo/utils"
|
"github.com/drakkan/sftpgo/utils"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
|
||||||
"github.com/drakkan/sftpgo/dataprovider"
|
"github.com/drakkan/sftpgo/dataprovider"
|
||||||
"github.com/drakkan/sftpgo/logger"
|
"github.com/drakkan/sftpgo/logger"
|
||||||
|
@ -94,6 +95,11 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||||
return nil, sftp.ErrSshFxNoSuchFile
|
return nil, sftp.ErrSshFxNoSuchFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filePath := p
|
||||||
|
if uploadMode == uploadModeAtomic {
|
||||||
|
filePath = getUploadTempFilePath(p)
|
||||||
|
}
|
||||||
|
|
||||||
c.lock.Lock()
|
c.lock.Lock()
|
||||||
defer c.lock.Unlock()
|
defer c.lock.Unlock()
|
||||||
|
|
||||||
|
@ -101,45 +107,7 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||||
// If the file doesn't exist we need to create it, as well as the directory pathway
|
// If the file doesn't exist we need to create it, as well as the directory pathway
|
||||||
// leading up to where that file will be created.
|
// leading up to where that file will be created.
|
||||||
if os.IsNotExist(statErr) {
|
if os.IsNotExist(statErr) {
|
||||||
if !c.hasSpace(true) {
|
return c.handleSFTPUploadToNewFile(p, filePath)
|
||||||
logger.Info(logSender, "denying file write due to space limit")
|
|
||||||
return nil, sftp.ErrSshFxFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Dir(p)); os.IsNotExist(err) {
|
|
||||||
if !c.User.HasPerm(dataprovider.PermCreateDirs) {
|
|
||||||
return nil, sftp.ErrSshFxPermissionDenied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = c.createMissingDirs(p)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(logSender, "error making missing dir for path %v: %v", p, err)
|
|
||||||
return nil, sftp.ErrSshFxFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Create(p)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(logSender, "error creating file %v: %v", p, err)
|
|
||||||
return nil, sftp.ErrSshFxFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SetPathPermissions(p, c.User.GetUID(), c.User.GetGID())
|
|
||||||
|
|
||||||
transfer := Transfer{
|
|
||||||
file: file,
|
|
||||||
path: p,
|
|
||||||
start: time.Now(),
|
|
||||||
bytesSent: 0,
|
|
||||||
bytesReceived: 0,
|
|
||||||
user: c.User,
|
|
||||||
connectionID: c.ID,
|
|
||||||
transferType: transferUpload,
|
|
||||||
lastActivity: time.Now(),
|
|
||||||
isNewFile: true,
|
|
||||||
}
|
|
||||||
addTransfer(&transfer)
|
|
||||||
return &transfer, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if statErr != nil {
|
if statErr != nil {
|
||||||
|
@ -147,53 +115,13 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||||
return nil, sftp.ErrSshFxFailure
|
return nil, sftp.ErrSshFxFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.hasSpace(false) {
|
// This happen if we upload a file that has the same name of an existing directory
|
||||||
logger.Info(logSender, "denying file write due to space limit")
|
|
||||||
return nil, sftp.ErrSshFxFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not sure this would ever happen, but lets not find out.
|
|
||||||
if stat.IsDir() {
|
if stat.IsDir() {
|
||||||
logger.Warn(logSender, "attempted to open a directory for writing to: %v", p)
|
logger.Warn(logSender, "attempted to open a directory for writing to: %v", p)
|
||||||
return nil, sftp.ErrSshFxOpUnsupported
|
return nil, sftp.ErrSshFxOpUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
osFlags, trunc := getOSOpenFlags(request.Pflags())
|
return c.handleSFTPUploadToExistingFile(request.Pflags(), p, filePath, stat.Size())
|
||||||
|
|
||||||
if !trunc {
|
|
||||||
// see https://github.com/pkg/sftp/issues/295
|
|
||||||
logger.Info(logSender, "upload resume is not supported, returning error")
|
|
||||||
return nil, sftp.ErrSshFxOpUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// we use 0666 so the umask is applied
|
|
||||||
file, err := os.OpenFile(p, osFlags, 0666)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(logSender, "error opening existing file, flags: %v, source: %v, err: %v", request.Flags, p, err)
|
|
||||||
return nil, sftp.ErrSshFxFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
if trunc {
|
|
||||||
// the file is truncated so we need to decrease quota size but not quota files
|
|
||||||
dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -stat.Size(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SetPathPermissions(p, c.User.GetUID(), c.User.GetGID())
|
|
||||||
|
|
||||||
transfer := Transfer{
|
|
||||||
file: file,
|
|
||||||
path: p,
|
|
||||||
start: time.Now(),
|
|
||||||
bytesSent: 0,
|
|
||||||
bytesReceived: 0,
|
|
||||||
user: c.User,
|
|
||||||
connectionID: c.ID,
|
|
||||||
transferType: transferUpload,
|
|
||||||
lastActivity: time.Now(),
|
|
||||||
isNewFile: false,
|
|
||||||
}
|
|
||||||
addTransfer(&transfer)
|
|
||||||
return &transfer, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading
|
// Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading
|
||||||
|
@ -407,6 +335,101 @@ func (c Connection) handleSFTPRemove(path string) error {
|
||||||
return sftp.ErrSshFxOk
|
return sftp.ErrSshFxOk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string) (io.WriterAt, error) {
|
||||||
|
if !c.hasSpace(true) {
|
||||||
|
logger.Info(logSender, "denying file write due to space limit")
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Dir(requestPath)); os.IsNotExist(err) {
|
||||||
|
if !c.User.HasPerm(dataprovider.PermCreateDirs) {
|
||||||
|
return nil, sftp.ErrSshFxPermissionDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.createMissingDirs(requestPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(logSender, "error making missing dir for path %v: %v", requestPath, err)
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(logSender, "error creating file %v: %v", requestPath, err)
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SetPathPermissions(filePath, c.User.GetUID(), c.User.GetGID())
|
||||||
|
|
||||||
|
transfer := Transfer{
|
||||||
|
file: file,
|
||||||
|
path: requestPath,
|
||||||
|
start: time.Now(),
|
||||||
|
bytesSent: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
user: c.User,
|
||||||
|
connectionID: c.ID,
|
||||||
|
transferType: transferUpload,
|
||||||
|
lastActivity: time.Now(),
|
||||||
|
isNewFile: true,
|
||||||
|
}
|
||||||
|
addTransfer(&transfer)
|
||||||
|
return &transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, requestPath, filePath string,
|
||||||
|
fileSize int64) (io.WriterAt, error) {
|
||||||
|
var err error
|
||||||
|
if !c.hasSpace(false) {
|
||||||
|
logger.Info(logSender, "denying file write due to space limit")
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
osFlags, trunc := getOSOpenFlags(pflags)
|
||||||
|
|
||||||
|
if !trunc {
|
||||||
|
// see https://github.com/pkg/sftp/issues/295
|
||||||
|
logger.Info(logSender, "upload resume is not supported, returning error")
|
||||||
|
return nil, sftp.ErrSshFxOpUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if uploadMode == uploadModeAtomic {
|
||||||
|
err = os.Rename(requestPath, filePath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(logSender, "error renaming existing file for atomic upload, source: %v, dest: %v, err: %v",
|
||||||
|
requestPath, filePath, err)
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// we use 0666 so the umask is applied
|
||||||
|
file, err := os.OpenFile(filePath, osFlags, 0666)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(logSender, "error opening existing file, flags: %v, source: %v, err: %v", pflags, filePath, err)
|
||||||
|
return nil, sftp.ErrSshFxFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: this need to be changed when we add upload resume support
|
||||||
|
// the file is truncated so we need to decrease quota size but not quota files
|
||||||
|
dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false)
|
||||||
|
|
||||||
|
utils.SetPathPermissions(filePath, c.User.GetUID(), c.User.GetGID())
|
||||||
|
|
||||||
|
transfer := Transfer{
|
||||||
|
file: file,
|
||||||
|
path: requestPath,
|
||||||
|
start: time.Now(),
|
||||||
|
bytesSent: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
user: c.User,
|
||||||
|
connectionID: c.ID,
|
||||||
|
transferType: transferUpload,
|
||||||
|
lastActivity: time.Now(),
|
||||||
|
isNewFile: false,
|
||||||
|
}
|
||||||
|
addTransfer(&transfer)
|
||||||
|
return &transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c Connection) hasSpace(checkFiles bool) bool {
|
func (c Connection) hasSpace(checkFiles bool) bool {
|
||||||
if (checkFiles && c.User.QuotaFiles > 0) || c.User.QuotaSize > 0 {
|
if (checkFiles && c.User.QuotaFiles > 0) || c.User.QuotaSize > 0 {
|
||||||
numFile, size, err := dataprovider.GetUsedQuota(dataProvider, c.User.Username)
|
numFile, size, err := dataprovider.GetUsedQuota(dataProvider, c.User.Username)
|
||||||
|
@ -565,3 +588,9 @@ func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int, trunc bool) {
|
||||||
}
|
}
|
||||||
return osFlags, truncateFile
|
return osFlags, truncateFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUploadTempFilePath(path string) string {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
guid := xid.New().String()
|
||||||
|
return filepath.Join(dir, ".sftpgo-upload."+guid+"."+filepath.Base(path))
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package sftpd
|
package sftpd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pkg/sftp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWrongActions(t *testing.T) {
|
func TestWrongActions(t *testing.T) {
|
||||||
|
@ -20,6 +23,10 @@ func TestWrongActions(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("action with bad command must fail")
|
t.Errorf("action with bad command must fail")
|
||||||
}
|
}
|
||||||
|
err = executeAction(operationDelete, "username", "path", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("action not configured must silently fail")
|
||||||
|
}
|
||||||
actions.Command = ""
|
actions.Command = ""
|
||||||
actions.HTTPNotificationURL = "http://foo\x7f.com/"
|
actions.HTTPNotificationURL = "http://foo\x7f.com/"
|
||||||
err = executeAction(operationDownload, "username", "path", "")
|
err = executeAction(operationDownload, "username", "path", "")
|
||||||
|
@ -43,3 +50,23 @@ func TestRemoveNonexistentQuotaScan(t *testing.T) {
|
||||||
t.Errorf("remove nonexistent transfer must fail")
|
t.Errorf("remove nonexistent transfer must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetOSOpenFlags(t *testing.T) {
|
||||||
|
var flags sftp.FileOpenFlags
|
||||||
|
flags.Write = true
|
||||||
|
flags.Append = true
|
||||||
|
flags.Excl = true
|
||||||
|
osFlags, _ := getOSOpenFlags(flags)
|
||||||
|
if osFlags&os.O_WRONLY == 0 || osFlags&os.O_APPEND == 0 || osFlags&os.O_EXCL == 0 {
|
||||||
|
t.Errorf("error getting os flags from sftp file open flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadResume(t *testing.T) {
|
||||||
|
c := Connection{}
|
||||||
|
var flags sftp.FileOpenFlags
|
||||||
|
_, err := c.handleSFTPUploadToExistingFile(flags, "", "", 0)
|
||||||
|
if err != sftp.ErrSshFxOpUnsupported {
|
||||||
|
t.Errorf("file resume is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -43,6 +43,11 @@ type Configuration struct {
|
||||||
MaxAuthTries int `json:"max_auth_tries"`
|
MaxAuthTries int `json:"max_auth_tries"`
|
||||||
// Umask for new files
|
// Umask for new files
|
||||||
Umask string `json:"umask"`
|
Umask string `json:"umask"`
|
||||||
|
// UploadMode 0 means standard, the files are uploaded directly to the requested path.
|
||||||
|
// 1 means atomic: the files are uploaded to a temporary path and renamed to the requested path
|
||||||
|
// when the client ends the upload. Atomic mode avoid problems such as a web server that
|
||||||
|
// serves partial files when the files are being uploaded.
|
||||||
|
UploadMode int `json:"upload_mode"`
|
||||||
// Actions to execute on SFTP create, download, delete and rename
|
// Actions to execute on SFTP create, download, delete and rename
|
||||||
Actions Actions `json:"actions"`
|
Actions Actions `json:"actions"`
|
||||||
// Keys are a list of host keys
|
// Keys are a list of host keys
|
||||||
|
@ -119,6 +124,7 @@ func (c Configuration) Initialize(configDir string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
actions = c.Actions
|
actions = c.Actions
|
||||||
|
uploadMode = c.UploadMode
|
||||||
logger.Info(logSender, "server listener registered address: %v", listener.Addr().String())
|
logger.Info(logSender, "server listener registered address: %v", listener.Addr().String())
|
||||||
if c.IdleTimeout > 0 {
|
if c.IdleTimeout > 0 {
|
||||||
startIdleTimer(time.Duration(c.IdleTimeout) * time.Minute)
|
startIdleTimer(time.Duration(c.IdleTimeout) * time.Minute)
|
||||||
|
|
|
@ -42,6 +42,7 @@ var (
|
||||||
activeQuotaScans []ActiveQuotaScan
|
activeQuotaScans []ActiveQuotaScan
|
||||||
dataProvider dataprovider.Provider
|
dataProvider dataprovider.Provider
|
||||||
actions Actions
|
actions Actions
|
||||||
|
uploadMode int
|
||||||
)
|
)
|
||||||
|
|
||||||
type connectionTransfer struct {
|
type connectionTransfer struct {
|
||||||
|
|
|
@ -98,11 +98,15 @@ func TestMain(m *testing.M) {
|
||||||
sftpdConf := config.GetSFTPDConfig()
|
sftpdConf := config.GetSFTPDConfig()
|
||||||
httpdConf := config.GetHTTPDConfig()
|
httpdConf := config.GetHTTPDConfig()
|
||||||
router := api.GetHTTPRouter()
|
router := api.GetHTTPRouter()
|
||||||
|
// we run the test cases with UploadMode atomic. The non atomic code path
|
||||||
|
// simply does not execute some code so if it works in atomic mode will
|
||||||
|
// work in non atomic mode too
|
||||||
|
sftpdConf.UploadMode = 1
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
homeBasePath = "C:\\"
|
homeBasePath = "C:\\"
|
||||||
} else {
|
} else {
|
||||||
homeBasePath = "/tmp"
|
homeBasePath = "/tmp"
|
||||||
sftpdConf.Actions.ExecuteOn = []string{"download", "upload", "rename"}
|
sftpdConf.Actions.ExecuteOn = []string{"download", "upload", "rename", "delete"}
|
||||||
sftpdConf.Actions.Command = "/bin/true"
|
sftpdConf.Actions.Command = "/bin/true"
|
||||||
sftpdConf.Actions.HTTPNotificationURL = "http://127.0.0.1:8080/"
|
sftpdConf.Actions.HTTPNotificationURL = "http://127.0.0.1:8080/"
|
||||||
}
|
}
|
||||||
|
@ -231,14 +235,6 @@ func TestDirCommands(t *testing.T) {
|
||||||
t.Errorf("unable to create sftp client: %v", err)
|
t.Errorf("unable to create sftp client: %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
_, err := client.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unable to get working dir: %v", err)
|
|
||||||
}
|
|
||||||
_, err = client.ReadDir(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unable to read remote dir: %v", err)
|
|
||||||
}
|
|
||||||
err = client.Mkdir("test")
|
err = client.Mkdir("test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error mkdir: %v", err)
|
t.Errorf("error mkdir: %v", err)
|
||||||
|
@ -251,10 +247,24 @@ func TestDirCommands(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error rmdir: %v", err)
|
t.Errorf("error rmdir: %v", err)
|
||||||
}
|
}
|
||||||
err = client.MkdirAll("/test/test")
|
err = client.Mkdir("/test/test1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error mkdir all: %v", err)
|
t.Errorf("error mkdir all: %v", err)
|
||||||
}
|
}
|
||||||
|
testFileName := "/test_file.dat"
|
||||||
|
testFilePath := filepath.Join(homeBasePath, testFileName)
|
||||||
|
testFileSize := int64(65535)
|
||||||
|
err = createTestFile(testFilePath, testFileSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to create test file: %v", err)
|
||||||
|
}
|
||||||
|
err = sftpUploadFile(testFilePath, filepath.Join("/test", testFileName), testFileSize, client)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("file upload error: %v", err)
|
||||||
|
}
|
||||||
|
// internally client.Remove will call RemoveDirectory on failure
|
||||||
|
// the first remove will fail since test directory is not empty
|
||||||
|
// the RemoveDirectory called internally by client.Remove will succeed
|
||||||
err = client.Remove("/test")
|
err = client.Remove("/test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error rmdir all: %v", err)
|
t.Errorf("error rmdir all: %v", err)
|
||||||
|
@ -263,6 +273,10 @@ func TestDirCommands(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("stat for deleted dir must not succeed")
|
t.Errorf("stat for deleted dir must not succeed")
|
||||||
}
|
}
|
||||||
|
err = client.Remove("/test")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("remove missing path must fail")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = api.RemoveUser(user, http.StatusOK)
|
err = api.RemoveUser(user, http.StatusOK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -311,7 +325,7 @@ func TestSymlink(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetStat(t *testing.T) {
|
func TestStat(t *testing.T) {
|
||||||
usePubKey := false
|
usePubKey := false
|
||||||
user, err := api.AddUser(getTestUser(usePubKey), http.StatusOK)
|
user, err := api.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -352,6 +366,10 @@ func TestSetStat(t *testing.T) {
|
||||||
if fi.Mode().Perm() != newFi.Mode().Perm() {
|
if fi.Mode().Perm() != newFi.Mode().Perm() {
|
||||||
t.Errorf("stat must remain unchanged")
|
t.Errorf("stat must remain unchanged")
|
||||||
}
|
}
|
||||||
|
_, err = client.ReadLink(testFileName)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("readlink is not supported and must fail")
|
||||||
|
}
|
||||||
err = client.Remove(testFileName)
|
err = client.Remove(testFileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error removing uploaded file: %v", err)
|
t.Errorf("error removing uploaded file: %v", err)
|
||||||
|
@ -410,6 +428,24 @@ func TestEscapeHomeDir(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("error removing uploaded file: %v", err)
|
t.Errorf("error removing uploaded file: %v", err)
|
||||||
}
|
}
|
||||||
|
linkPath = filepath.Join(homeBasePath, defaultUsername, testFileName)
|
||||||
|
err = os.Symlink(homeBasePath, linkPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error making local symlink: %v", err)
|
||||||
|
}
|
||||||
|
err = sftpDownloadFile(testFileName, testFilePath, 0, client)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("download file outside home dir must fail")
|
||||||
|
}
|
||||||
|
err = sftpUploadFile(testFilePath, remoteDestPath, testFileSize, client)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("overwrite a file outside home dir must fail")
|
||||||
|
}
|
||||||
|
err = client.Chmod(remoteDestPath, 0644)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("setstat on a file outside home dir must fail")
|
||||||
|
}
|
||||||
|
os.Remove(linkPath)
|
||||||
}
|
}
|
||||||
err = api.RemoveUser(user, http.StatusOK)
|
err = api.RemoveUser(user, http.StatusOK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -886,6 +922,81 @@ func TestBandwidthAndConnections(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMissingFile(t *testing.T) {
|
||||||
|
usePubKey := false
|
||||||
|
u := getTestUser(usePubKey)
|
||||||
|
user, err := api.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()
|
||||||
|
localDownloadPath := filepath.Join(homeBasePath, "test_download.dat")
|
||||||
|
err = sftpDownloadFile("missing_file", localDownloadPath, 0, client)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("download missing file must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = api.RemoveUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to remove user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverwriteDirWithFile(t *testing.T) {
|
||||||
|
usePubKey := false
|
||||||
|
u := getTestUser(usePubKey)
|
||||||
|
user, err := api.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(65535)
|
||||||
|
testFileName := "test_file.dat"
|
||||||
|
testDirName := "test_dir"
|
||||||
|
testFilePath := filepath.Join(homeBasePath, testFileName)
|
||||||
|
err = createTestFile(testFilePath, testFileSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to create test file: %v", err)
|
||||||
|
}
|
||||||
|
err = client.Mkdir(testDirName)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("mkdir error: %v", err)
|
||||||
|
}
|
||||||
|
err = sftpUploadFile(testFilePath, testDirName, testFileSize, client)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("copying a file over an existing dir must fail")
|
||||||
|
}
|
||||||
|
err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("file upload error: %v", err)
|
||||||
|
}
|
||||||
|
err = client.Rename(testFileName, testDirName)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("rename a file over an existing dir must fail")
|
||||||
|
}
|
||||||
|
err = client.RemoveDirectory(testDirName)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("dir remove error: %v", err)
|
||||||
|
}
|
||||||
|
err = client.Remove(testFileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error removing uploaded file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = api.RemoveUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to remove user: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPermList(t *testing.T) {
|
func TestPermList(t *testing.T) {
|
||||||
usePubKey := true
|
usePubKey := true
|
||||||
u := getTestUser(usePubKey)
|
u := getTestUser(usePubKey)
|
||||||
|
@ -1262,8 +1373,14 @@ func sftpUploadFile(localSourcePath string, remoteDestPath string, expectedSize
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer destFile.Close()
|
|
||||||
_, err = io.Copy(destFile, srcFile)
|
_, err = io.Copy(destFile, srcFile)
|
||||||
|
if err != nil {
|
||||||
|
destFile.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// we need to close the file to trigger the close method on server
|
||||||
|
// we cannot defer closing or Lstat will fail for upload atomic mode
|
||||||
|
destFile.Close()
|
||||||
if expectedSize > 0 {
|
if expectedSize > 0 {
|
||||||
fi, err := client.Lstat(remoteDestPath)
|
fi, err := client.Lstat(remoteDestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -13,6 +13,11 @@ const (
|
||||||
transferDownload
|
transferDownload
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
uploadModeStandard = iota
|
||||||
|
uploadModeAtomic
|
||||||
|
)
|
||||||
|
|
||||||
// Transfer contains the transfer details for an upload or a download.
|
// Transfer contains the transfer details for an upload or a download.
|
||||||
// It implements the io Reader and Writer interface to handle files downloads and uploads
|
// It implements the io Reader and Writer interface to handle files downloads and uploads
|
||||||
type Transfer struct {
|
type Transfer struct {
|
||||||
|
@ -52,6 +57,11 @@ func (t *Transfer) WriteAt(p []byte, off int64) (n int, err error) {
|
||||||
// It closes the underlying file, log the transfer info, update the user quota, for uploads, and execute any defined actions.
|
// It closes the underlying file, log the transfer info, update the user quota, for uploads, and execute any defined actions.
|
||||||
func (t *Transfer) Close() error {
|
func (t *Transfer) Close() error {
|
||||||
err := t.file.Close()
|
err := t.file.Close()
|
||||||
|
if t.transferType == transferUpload && t.file.Name() != t.path {
|
||||||
|
err = os.Rename(t.file.Name(), t.path)
|
||||||
|
logger.Debug(logSender, "atomic upload completed, rename: \"%v\" -> \"%v\", error: %v",
|
||||||
|
t.file.Name(), t.path, err)
|
||||||
|
}
|
||||||
elapsed := time.Since(t.start).Nanoseconds() / 1000000
|
elapsed := time.Since(t.start).Nanoseconds() / 1000000
|
||||||
if t.transferType == transferDownload {
|
if t.transferType == transferDownload {
|
||||||
logger.TransferLog(sftpdDownloadLogSender, t.path, elapsed, t.bytesSent, t.user.Username, t.connectionID)
|
logger.TransferLog(sftpdDownloadLogSender, t.path, elapsed, t.bytesSent, t.user.Username, t.connectionID)
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
"max_auth_tries": 0,
|
"max_auth_tries": 0,
|
||||||
"umask": "0022",
|
"umask": "0022",
|
||||||
"banner": "SFTPGo",
|
"banner": "SFTPGo",
|
||||||
|
"upload_mode": 0,
|
||||||
"actions": {
|
"actions": {
|
||||||
"execute_on": [],
|
"execute_on": [],
|
||||||
"command": "",
|
"command": "",
|
||||||
|
|
Loading…
Reference in a new issue