sftpd actions: add support for pre-delete action

Fixes #121
This commit is contained in:
Nicola Murino
2020-05-24 23:31:14 +02:00
parent c27e3ef436
commit dc011af90d
7 changed files with 90 additions and 29 deletions

View File

@@ -503,7 +503,7 @@ func AddUser(p Provider, user User) error {
} }
err := p.addUser(user) err := p.addUser(user)
if err == nil { if err == nil {
go executeAction(operationAdd, user) //nolint:errcheck // the error is used in test cases only go executeAction(operationAdd, user)
} }
return err return err
} }
@@ -516,7 +516,7 @@ func UpdateUser(p Provider, user User) error {
} }
err := p.updateUser(user) err := p.updateUser(user)
if err == nil { if err == nil {
go executeAction(operationUpdate, user) //nolint:errcheck // the error is used in test cases only go executeAction(operationUpdate, user)
} }
return err return err
} }
@@ -529,7 +529,7 @@ func DeleteUser(p Provider, user User) error {
} }
err := p.deleteUser(user) err := p.deleteUser(user)
if err == nil { if err == nil {
go executeAction(operationDelete, user) //nolint:errcheck // the error is used in test cases only go executeAction(operationDelete, user)
} }
return err return err
} }

View File

@@ -5,10 +5,11 @@ The `hook` can be defined as the absolute path of your program or an HTTP URL.
The `upload` condition includes both uploads to new files and overwrite of existing files. The `ssh_cmd` condition will be triggered after a command is successfully executed via SSH. `scp` will trigger the `download` and `upload` conditions and not `ssh_cmd`. The `upload` condition includes both uploads to new files and overwrite of existing files. The `ssh_cmd` condition will be triggered after a command is successfully executed via SSH. `scp` will trigger the `download` and `upload` conditions and not `ssh_cmd`.
The notification will indicate if an error is detected and so, for example, a partial file is uploaded. The notification will indicate if an error is detected and so, for example, a partial file is uploaded.
The `pre-delete` action, if defined, will be called just before files deletion. If the external command completes with a zero exit status or the HTTP notification response code is `200` then SFTPGo will assume that the file was already deleted/moved and so it will not try to remove the file and it will not execute the hook defined for the `delete` action.
If the `hook` defines a path to an external program, then this program is invoked with the following arguments: If the `hook` defines a path to an external program, then this program is invoked with the following arguments:
- `action`, string, possible values are: `download`, `upload`, `delete`, `rename`, `ssh_cmd` - `action`, string, possible values are: `download`, `upload`, `pre-delete`,`delete`, `rename`, `ssh_cmd`
- `username` - `username`
- `path` is the full filesystem path, can be empty for some ssh commands - `path` is the full filesystem path, can be empty for some ssh commands
- `target_path`, non-empty for `rename` action - `target_path`, non-empty for `rename` action

View File

@@ -47,9 +47,10 @@ The configuration file contains the following sections:
- `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_<version>`, for example `SSH-2.0-SFTPGo_0.9.5` - `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_<version>`, for example `SSH-2.0-SFTPGo_0.9.5`
- `upload_mode` integer. 0 means standard: the files are uploaded directly to the requested path. 1 means atomic: files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoids problems such as a web server that serves partial files when the files are being uploaded. In atomic mode, if there is an upload error, the temporary file is deleted and so the requested upload path will not contain a partial file. 2 means atomic with resume support: same as atomic but if there is an upload error, the temporary file is renamed to the requested path and not deleted. This way, a client can reconnect and resume the upload. - `upload_mode` integer. 0 means standard: the files are uploaded directly to the requested path. 1 means atomic: files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoids problems such as a web server that serves partial files when the files are being uploaded. In atomic mode, if there is an upload error, the temporary file is deleted and so the requested upload path will not contain a partial file. 2 means atomic with resume support: same as atomic but if there is an upload error, the temporary file is renamed to the requested path and not deleted. This way, a client can reconnect and resume the upload.
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details
- `execute_on`, list of strings. Valid values are `download`, `upload`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions. - `execute_on`, list of strings. Valid values are `download`, `upload`, `pre-delete`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions.
- `command`, string. Absolute path to the command to execute. Leave empty to disable. - `command`, string. Deprecated please use `hook`.
- `http_notification_url`, a valid URL. An HTTP GET request will be executed to this URL. Leave empty to disable. - `http_notification_url`, a valid URL. Deprecated please use `hook`.
- `hook`, string. Absolute path to the command to execute or HTTP URL to notify.
- `keys`, struct array. Deprecated, please use `host_keys`. - `keys`, struct array. Deprecated, please use `host_keys`.
- `private_key`, path to the private key file. It can be a path relative to the config dir or an absolute one. - `private_key`, path to the private key file. It can be a path relative to the config dir or an absolute one.
- `host_keys`, list of strings. It contains the daemon's private host keys. Each host key can be defined as a path relative to the configuration directory or an absolute one. If empty or missing, the daemon will search or try to generate `id_rsa` and `id_ecdsa` keys inside the configuration directory. - `host_keys`, list of strings. It contains the daemon's private host keys. Each host key can be defined as a path relative to the configuration directory or an absolute one. If empty or missing, the daemon will search or try to generate `id_rsa` and `id_ecdsa` keys inside the configuration directory.
@@ -93,8 +94,9 @@ The configuration file contains the following sections:
- `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username - `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username
- `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details
- `execute_on`, list of strings. Valid values are `add`, `update`, `delete`. `update` action will not be fired for internal updates such as the last login or the user quota fields. - `execute_on`, list of strings. Valid values are `add`, `update`, `delete`. `update` action will not be fired for internal updates such as the last login or the user quota fields.
- `command`, string. Absolute path to the command to execute. Leave empty to disable. - `command`, string. Deprecated please use `hook`.
- `http_notification_url`, a valid URL. Leave empty to disable. - `http_notification_url`, a valid URL. Deprecated please use `hook`.
- `hook`, string. Absolute path to the command to execute or HTTP URL to notify.
- `external_auth_program`, string. Deprecated, please use `external_auth_hook`. - `external_auth_program`, string. Deprecated, please use `external_auth_hook`.
- `external_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for users authentication. See the "External Authentication" paragraph for more details. Leave empty to disable. - `external_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for users authentication. See the "External Authentication" paragraph for more details. Leave empty to disable.
- `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive - `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive

View File

@@ -425,9 +425,14 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err
} }
size = fi.Size() size = fi.Size()
if err := c.fs.Remove(filePath, false); err != nil { actionErr := executeAction(newActionNotification(c.User, operationPreDelete, filePath, "", "", fi.Size(), nil))
c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %+v", filePath, err) if actionErr == nil {
return vfs.GetSFTPError(c.fs, err) c.Log(logger.LevelDebug, logSender, "remove for path %#v handled by pre-delete action", filePath)
} else {
if err := c.fs.Remove(filePath, false); err != nil {
c.Log(logger.LevelWarn, logSender, "failed to remove a file/symlink %#v: %+v", filePath, err)
return vfs.GetSFTPError(c.fs, err)
}
} }
logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "") logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "")
@@ -436,7 +441,9 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err
dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck
} }
} }
go executeAction(newActionNotification(c.User, operationDelete, filePath, "", "", fi.Size(), nil)) //nolint:errcheck if actionErr != nil {
go executeAction(newActionNotification(c.User, operationDelete, filePath, "", "", fi.Size(), nil)) //nolint:errcheck
}
return sftp.ErrSSHFxOk return sftp.ErrSSHFxOk
} }

View File

@@ -8,6 +8,7 @@ import (
"io/ioutil" "io/ioutil"
"net" "net"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync" "sync"
@@ -187,6 +188,7 @@ func TestWrongActions(t *testing.T) {
func TestActionHTTP(t *testing.T) { func TestActionHTTP(t *testing.T) {
actionsCopy := actions actionsCopy := actions
actions = Actions{ actions = Actions{
ExecuteOn: []string{operationDownload}, ExecuteOn: []string{operationDownload},
Hook: "http://127.0.0.1:8080/", Hook: "http://127.0.0.1:8080/",
@@ -195,7 +197,42 @@ func TestActionHTTP(t *testing.T) {
Username: "username", Username: "username",
} }
err := executeAction(newActionNotification(user, operationDownload, "path", "", "", 0, nil)) err := executeAction(newActionNotification(user, operationDownload, "path", "", "", 0, nil))
assert.EqualError(t, err, errUnexpectedHTTResponse.Error())
actions = actionsCopy
}
func TestPreDeleteAction(t *testing.T) {
actionsCopy := actions
hookCmd, err := exec.LookPath("true")
assert.NoError(t, err) assert.NoError(t, err)
actions = Actions{
ExecuteOn: []string{operationPreDelete},
Hook: hookCmd,
}
homeDir := filepath.Join(os.TempDir(), "test_user")
err = os.MkdirAll(homeDir, 0777)
assert.NoError(t, err)
user := dataprovider.User{
Username: "username",
HomeDir: homeDir,
}
user.Permissions = make(map[string][]string)
user.Permissions["/"] = []string{dataprovider.PermAny}
c := Connection{
fs: vfs.NewOsFs("id", homeDir, nil),
User: user,
}
testfile := filepath.Join(user.HomeDir, "testfile")
request := sftp.NewRequest("Remove", "/testfile")
err = ioutil.WriteFile(testfile, []byte("test"), 0666)
assert.NoError(t, err)
err = c.handleSFTPRemove(testfile, request)
assert.EqualError(t, err, sftp.ErrSSHFxOk.Error())
assert.FileExists(t, testfile)
os.RemoveAll(homeDir)
actions = actionsCopy actions = actionsCopy
} }

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
@@ -42,6 +43,7 @@ const (
operationDownload = "download" operationDownload = "download"
operationUpload = "upload" operationUpload = "upload"
operationDelete = "delete" operationDelete = "delete"
operationPreDelete = "pre-delete"
operationRename = "rename" operationRename = "rename"
operationSSHCmd = "ssh_cmd" operationSSHCmd = "ssh_cmd"
protocolSFTP = "SFTP" protocolSFTP = "SFTP"
@@ -68,11 +70,12 @@ var (
setstatMode int setstatMode int
supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd",
"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"} defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"}
sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
errUnconfiguredAction = errors.New("no hook is configured for this action") errUnconfiguredAction = errors.New("no hook is configured for this action")
errNoHook = errors.New("unable to execute action, no hook defined") errNoHook = errors.New("unable to execute action, no hook defined")
errUnexpectedHTTResponse = errors.New("unexpected HTTP response code")
) )
type connectionTransfer struct { type connectionTransfer struct {
@@ -496,7 +499,6 @@ func executeNotificationCommand(a actionNotification) error {
return err return err
} }
// executed in a goroutine
func executeAction(a actionNotification) error { func executeAction(a actionNotification) error {
if !utils.IsStringInSlice(a.Action, actions.ExecuteOn) { if !utils.IsStringInSlice(a.Action, actions.ExecuteOn) {
return errUnconfiguredAction return errUnconfiguredAction
@@ -519,6 +521,9 @@ func executeAction(a actionNotification) error {
if err == nil { if err == nil {
respCode = resp.StatusCode respCode = resp.StatusCode
resp.Body.Close() resp.Body.Close()
if respCode != http.StatusOK {
err = errUnexpectedHTTResponse
}
} }
logger.Debug(logSender, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v", logger.Debug(logSender, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v",
a.Action, url.String(), respCode, time.Since(startTime), err) a.Action, url.String(), respCode, time.Since(startTime), err)

View File

@@ -116,6 +116,7 @@ var (
scpPath string scpPath string
gitPath string gitPath string
sshPath string sshPath string
hookCmdPath string
pubKeyPath string pubKeyPath string
privateKeyPath string privateKeyPath string
trustedCAUserKey string trustedCAUserKey string
@@ -134,7 +135,6 @@ func TestMain(m *testing.M) {
err := ioutil.WriteFile(loginBannerFile, []byte("simple login banner\n"), 0777) err := ioutil.WriteFile(loginBannerFile, []byte("simple login banner\n"), 0777)
if err != nil { if err != nil {
logger.WarnToConsole("error creating login banner: %v", err) logger.WarnToConsole("error creating login banner: %v", err)
os.Exit(1)
} }
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
if err != nil { if err != nil {
@@ -169,21 +169,15 @@ func TestMain(m *testing.M) {
// work in non atomic mode too // work in non atomic mode too
sftpdConf.UploadMode = 2 sftpdConf.UploadMode = 2
homeBasePath = os.TempDir() homeBasePath = os.TempDir()
checkSystemCommands()
var scriptArgs string var scriptArgs string
if runtime.GOOS == osWindows { if runtime.GOOS == osWindows {
scriptArgs = "%*" scriptArgs = "%*"
} else { } else {
sftpdConf.Actions.ExecuteOn = []string{"download", "upload", "rename", "delete", "ssh_cmd"} sftpdConf.Actions.ExecuteOn = []string{"download", "upload", "rename", "delete", "ssh_cmd"}
sftpdConf.Actions.Hook = "/bin/true" sftpdConf.Actions.Hook = hookCmdPath
scriptArgs = "$@" scriptArgs = "$@"
scpPath, err = exec.LookPath("scp")
if err != nil {
logger.Warn(logSender, "", "unable to get scp command. SCP tests will be skipped, err: %v", err)
logger.WarnToConsole("unable to get scp command. SCP tests will be skipped, err: %v", err)
scpPath = ""
}
} }
checkGitCommand()
keyIntAuthPath = filepath.Join(homeBasePath, "keyintauth.sh") keyIntAuthPath = filepath.Join(homeBasePath, "keyintauth.sh")
err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755) err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755)
@@ -4447,7 +4441,7 @@ func waitQuotaScans() error {
return nil return nil
} }
func checkGitCommand() { func checkSystemCommands() {
var err error var err error
gitPath, err = exec.LookPath("git") gitPath, err = exec.LookPath("git")
if err != nil { if err != nil {
@@ -4462,6 +4456,21 @@ func checkGitCommand() {
logger.WarnToConsole("unable to get ssh command. GIT tests will be skipped, err: %v", err) logger.WarnToConsole("unable to get ssh command. GIT tests will be skipped, err: %v", err)
gitPath = "" gitPath = ""
} }
hookCmdPath, err = exec.LookPath("true")
if err != nil {
logger.Warn(logSender, "", "unable to get hook command: %v", err)
logger.WarnToConsole("unable to get hook command: %v", err)
}
if runtime.GOOS == osWindows {
scpPath = ""
} else {
scpPath, err = exec.LookPath("scp")
if err != nil {
logger.Warn(logSender, "", "unable to get scp command. SCP tests will be skipped, err: %v", err)
logger.WarnToConsole("unable to get scp command. SCP tests will be skipped, err: %v", err)
scpPath = ""
}
}
} }
func initGitRepo(path string) ([]byte, error) { func initGitRepo(path string) ([]byte, error) {