Browse Source

actions: add a generic hook to define external commands and HTTP URL

We can only define a single hook now and it can be an HTTP notification
or an external command, not both
Nicola Murino 5 years ago
parent
commit
c27e3ef436
8 changed files with 130 additions and 71 deletions
  1. 26 6
      config/config.go
  2. 32 0
      config/config_test.go
  3. 19 20
      dataprovider/dataprovider.go
  4. 9 8
      docs/custom-actions.md
  5. 14 9
      sftpd/internal_test.go
  6. 27 22
      sftpd/sftpd.go
  7. 1 2
      sftpd/sftpd_test.go
  8. 2 4
      sftpgo.json

+ 26 - 6
config/config.go

@@ -53,9 +53,8 @@ func init() {
 			Umask:        "0022",
 			Umask:        "0022",
 			UploadMode:   0,
 			UploadMode:   0,
 			Actions: sftpd.Actions{
 			Actions: sftpd.Actions{
-				ExecuteOn:           []string{},
-				Command:             "",
-				HTTPNotificationURL: "",
+				ExecuteOn: []string{},
+				Hook:      "",
 			},
 			},
 			HostKeys:                []string{},
 			HostKeys:                []string{},
 			KexAlgorithms:           []string{},
 			KexAlgorithms:           []string{},
@@ -83,9 +82,8 @@ func init() {
 			PoolSize:         0,
 			PoolSize:         0,
 			UsersBaseDir:     "",
 			UsersBaseDir:     "",
 			Actions: dataprovider.Actions{
 			Actions: dataprovider.Actions{
-				ExecuteOn:           []string{},
-				Command:             "",
-				HTTPNotificationURL: "",
+				ExecuteOn: []string{},
+				Hook:      "",
 			},
 			},
 			ExternalAuthHook:  "",
 			ExternalAuthHook:  "",
 			ExternalAuthScope: 0,
 			ExternalAuthScope: 0,
@@ -240,6 +238,28 @@ func checkHooksCompatibility() {
 		logger.WarnToConsole("keyboard_interactive_auth_program is deprecated, please use keyboard_interactive_auth_hook")
 		logger.WarnToConsole("keyboard_interactive_auth_program is deprecated, please use keyboard_interactive_auth_hook")
 		globalConf.SFTPD.KeyboardInteractiveHook = globalConf.SFTPD.KeyboardInteractiveProgram //nolint:staticcheck
 		globalConf.SFTPD.KeyboardInteractiveHook = globalConf.SFTPD.KeyboardInteractiveProgram //nolint:staticcheck
 	}
 	}
+	if len(globalConf.SFTPD.Actions.Hook) == 0 {
+		if len(globalConf.SFTPD.Actions.HTTPNotificationURL) > 0 { //nolint:staticcheck
+			logger.Warn(logSender, "", "http_notification_url is deprecated, please use hook")
+			logger.WarnToConsole("http_notification_url is deprecated, please use hook")
+			globalConf.SFTPD.Actions.Hook = globalConf.SFTPD.Actions.HTTPNotificationURL //nolint:staticcheck
+		} else if len(globalConf.SFTPD.Actions.Command) > 0 { //nolint:staticcheck
+			logger.Warn(logSender, "", "command is deprecated, please use hook")
+			logger.WarnToConsole("command is deprecated, please use hook")
+			globalConf.SFTPD.Actions.Hook = globalConf.SFTPD.Actions.Command //nolint:staticcheck
+		}
+	}
+	if len(globalConf.ProviderConf.Actions.Hook) == 0 {
+		if len(globalConf.ProviderConf.Actions.HTTPNotificationURL) > 0 { //nolint:staticcheck
+			logger.Warn(logSender, "", "http_notification_url is deprecated, please use hook")
+			logger.WarnToConsole("http_notification_url is deprecated, please use hook")
+			globalConf.ProviderConf.Actions.Hook = globalConf.ProviderConf.Actions.HTTPNotificationURL //nolint:staticcheck
+		} else if len(globalConf.ProviderConf.Actions.Command) > 0 { //nolint:staticcheck
+			logger.Warn(logSender, "", "command is deprecated, please use hook")
+			logger.WarnToConsole("command is deprecated, please use hook")
+			globalConf.ProviderConf.Actions.Hook = globalConf.ProviderConf.Actions.Command //nolint:staticcheck
+		}
+	}
 }
 }
 
 
 func checkHostKeyCompatibility() {
 func checkHostKeyCompatibility() {

+ 32 - 0
config/config_test.go

@@ -177,6 +177,7 @@ func TestHookCompatibity(t *testing.T) {
 	providerConf := config.GetProviderConf()
 	providerConf := config.GetProviderConf()
 	providerConf.ExternalAuthProgram = "ext_auth_program" //nolint:staticcheck
 	providerConf.ExternalAuthProgram = "ext_auth_program" //nolint:staticcheck
 	providerConf.PreLoginProgram = "pre_login_program"    //nolint:staticcheck
 	providerConf.PreLoginProgram = "pre_login_program"    //nolint:staticcheck
+	providerConf.Actions.Command = "/tmp/test_cmd"        //nolint:staticcheck
 	c := make(map[string]dataprovider.Config)
 	c := make(map[string]dataprovider.Config)
 	c["data_provider"] = providerConf
 	c["data_provider"] = providerConf
 	jsonConf, err := json.Marshal(c)
 	jsonConf, err := json.Marshal(c)
@@ -188,10 +189,26 @@ func TestHookCompatibity(t *testing.T) {
 	providerConf = config.GetProviderConf()
 	providerConf = config.GetProviderConf()
 	assert.Equal(t, "ext_auth_program", providerConf.ExternalAuthHook)
 	assert.Equal(t, "ext_auth_program", providerConf.ExternalAuthHook)
 	assert.Equal(t, "pre_login_program", providerConf.PreLoginHook)
 	assert.Equal(t, "pre_login_program", providerConf.PreLoginHook)
+	assert.Equal(t, "/tmp/test_cmd", providerConf.Actions.Hook)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+	providerConf.Actions.Hook = ""
+	providerConf.Actions.HTTPNotificationURL = "http://example.com/notify" //nolint:staticcheck
+	c = make(map[string]dataprovider.Config)
+	c["data_provider"] = providerConf
+	jsonConf, err = json.Marshal(c)
+	assert.NoError(t, err)
+	err = ioutil.WriteFile(configFilePath, jsonConf, 0666)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, tempConfigName)
+	assert.NoError(t, err)
+	providerConf = config.GetProviderConf()
+	assert.Equal(t, "http://example.com/notify", providerConf.Actions.Hook)
 	err = os.Remove(configFilePath)
 	err = os.Remove(configFilePath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf := config.GetSFTPDConfig()
 	sftpdConf.KeyboardInteractiveProgram = "key_int_program" //nolint:staticcheck
 	sftpdConf.KeyboardInteractiveProgram = "key_int_program" //nolint:staticcheck
+	sftpdConf.Actions.Command = "/tmp/sftp_cmd"              //nolint:staticcheck
 	cnf := make(map[string]sftpd.Configuration)
 	cnf := make(map[string]sftpd.Configuration)
 	cnf["sftpd"] = sftpdConf
 	cnf["sftpd"] = sftpdConf
 	jsonConf, err = json.Marshal(cnf)
 	jsonConf, err = json.Marshal(cnf)
@@ -202,6 +219,21 @@ func TestHookCompatibity(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	sftpdConf = config.GetSFTPDConfig()
 	sftpdConf = config.GetSFTPDConfig()
 	assert.Equal(t, "key_int_program", sftpdConf.KeyboardInteractiveHook)
 	assert.Equal(t, "key_int_program", sftpdConf.KeyboardInteractiveHook)
+	assert.Equal(t, "/tmp/sftp_cmd", sftpdConf.Actions.Hook)
+	err = os.Remove(configFilePath)
+	assert.NoError(t, err)
+	sftpdConf.Actions.Hook = ""
+	sftpdConf.Actions.HTTPNotificationURL = "http://example.com/sftp" //nolint:staticcheck
+	cnf = make(map[string]sftpd.Configuration)
+	cnf["sftpd"] = sftpdConf
+	jsonConf, err = json.Marshal(cnf)
+	assert.NoError(t, err)
+	err = ioutil.WriteFile(configFilePath, jsonConf, 0666)
+	assert.NoError(t, err)
+	err = config.LoadConfig(configDir, tempConfigName)
+	assert.NoError(t, err)
+	sftpdConf = config.GetSFTPDConfig()
+	assert.Equal(t, "http://example.com/sftp", sftpdConf.Actions.Hook)
 	err = os.Remove(configFilePath)
 	err = os.Remove(configFilePath)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 }
 }

+ 19 - 20
dataprovider/dataprovider.go

@@ -111,13 +111,12 @@ type schemaVersion struct {
 type Actions struct {
 type Actions struct {
 	// Valid values are add, update, delete. Empty slice to disable
 	// Valid values are add, update, delete. Empty slice to disable
 	ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
 	ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
-	// Absolute path to the command to execute, empty to disable
+	// Deprecated: please use Hook
 	Command string `json:"command" mapstructure:"command"`
 	Command string `json:"command" mapstructure:"command"`
-	// The URL to notify using an HTTP POST.
-	// The action is added to the query string. For example <url>?action=update.
-	// The user is sent serialized as json inside the POST body.
-	// Empty to disable
+	// Deprecated: please use Hook
 	HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
 	HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
+	// Absolute path to an external program or an HTTP URL
+	Hook string `json:"hook" mapstructure:"hook"`
 }
 }
 
 
 // Config provider configuration
 // Config provider configuration
@@ -1520,15 +1519,20 @@ func providerLog(level logger.LogLevel, format string, v ...interface{}) {
 }
 }
 
 
 func executeNotificationCommand(operation string, user User) error {
 func executeNotificationCommand(operation string, user User) error {
+	if !filepath.IsAbs(config.Actions.Hook) {
+		err := fmt.Errorf("invalid notification command %#v", config.Actions.Hook)
+		logger.Warn(logSender, "", "unable to execute notification command: %v", err)
+		return err
+	}
 	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
 	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
 	defer cancel()
 	defer cancel()
 	commandArgs := user.getNotificationFieldsAsSlice(operation)
 	commandArgs := user.getNotificationFieldsAsSlice(operation)
-	cmd := exec.CommandContext(ctx, config.Actions.Command, commandArgs...)
+	cmd := exec.CommandContext(ctx, config.Actions.Hook, commandArgs...)
 	cmd.Env = append(os.Environ(), user.getNotificationFieldsAsEnvVars(operation)...)
 	cmd.Env = append(os.Environ(), user.getNotificationFieldsAsEnvVars(operation)...)
 	startTime := time.Now()
 	startTime := time.Now()
 	err := cmd.Run()
 	err := cmd.Run()
 	providerLog(logger.LevelDebug, "executed command %#v with arguments: %+v, elapsed: %v, error: %v",
 	providerLog(logger.LevelDebug, "executed command %#v with arguments: %+v, elapsed: %v, error: %v",
-		config.Actions.Command, commandArgs, time.Since(startTime), err)
+		config.Actions.Hook, commandArgs, time.Since(startTime), err)
 	return err
 	return err
 }
 }
 
 
@@ -1537,6 +1541,9 @@ func executeAction(operation string, user User) {
 	if !utils.IsStringInSlice(operation, config.Actions.ExecuteOn) {
 	if !utils.IsStringInSlice(operation, config.Actions.ExecuteOn) {
 		return
 		return
 	}
 	}
+	if len(config.Actions.Hook) == 0 {
+		return
+	}
 	if operation != operationDelete {
 	if operation != operationDelete {
 		var err error
 		var err error
 		user, err = provider.userExists(user.Username)
 		user, err = provider.userExists(user.Username)
@@ -1545,21 +1552,11 @@ func executeAction(operation string, user User) {
 			return
 			return
 		}
 		}
 	}
 	}
-	if len(config.Actions.Command) > 0 && filepath.IsAbs(config.Actions.Command) {
-		// we are in a goroutine but if we have to send an HTTP notification we don't want to wait for the
-		// end of the command
-		if len(config.Actions.HTTPNotificationURL) > 0 {
-			go executeNotificationCommand(operation, user) //nolint:errcheck // the error is used in test cases only
-		} else {
-			executeNotificationCommand(operation, user) //nolint:errcheck // the error is used in test cases only
-		}
-	}
-	if len(config.Actions.HTTPNotificationURL) > 0 {
+	if strings.HasPrefix(config.Actions.Hook, "http") {
 		var url *url.URL
 		var url *url.URL
-		url, err := url.Parse(config.Actions.HTTPNotificationURL)
+		url, err := url.Parse(config.Actions.Hook)
 		if err != nil {
 		if err != nil {
-			providerLog(logger.LevelWarn, "Invalid http_notification_url %#v for operation %#v: %v", config.Actions.HTTPNotificationURL,
-				operation, err)
+			providerLog(logger.LevelWarn, "Invalid http_notification_url %#v for operation %#v: %v", config.Actions.Hook, operation, err)
 			return
 			return
 		}
 		}
 		q := url.Query()
 		q := url.Query()
@@ -1580,5 +1577,7 @@ func executeAction(operation string, user User) {
 		}
 		}
 		providerLog(logger.LevelDebug, "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v",
 		providerLog(logger.LevelDebug, "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v",
 			operation, url.String(), respCode, time.Since(startTime), err)
 			operation, url.String(), respCode, time.Since(startTime), err)
+	} else {
+		executeNotificationCommand(operation, user) //nolint:errcheck // the error is used in test cases only
 	}
 	}
 }
 }

+ 9 - 8
docs/custom-actions.md

@@ -1,11 +1,12 @@
 # Custom Actions
 # Custom Actions
 
 
 The `actions` struct inside the "sftpd" configuration section allows to configure the actions for file operations and SSH commands.
 The `actions` struct inside the "sftpd" configuration section allows to configure the actions for file operations and SSH commands.
+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 `command`, if defined, 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`, `delete`, `rename`, `ssh_cmd`
 - `username`
 - `username`
@@ -13,7 +14,7 @@ The `command`, if defined, is invoked with the following arguments:
 - `target_path`, non-empty for `rename` action
 - `target_path`, non-empty for `rename` action
 - `ssh_cmd`, non-empty for `ssh_cmd` action
 - `ssh_cmd`, non-empty for `ssh_cmd` action
 
 
-The `command` can also read the following environment variables:
+The external program can also read the following environment variables:
 
 
 - `SFTPGO_ACTION`
 - `SFTPGO_ACTION`
 - `SFTPGO_ACTION_USERNAME`
 - `SFTPGO_ACTION_USERNAME`
@@ -27,9 +28,9 @@ The `command` can also read the following environment variables:
 - `SFTPGO_ACTION_STATUS`, integer. 0 means an error occurred. 1 means no error
 - `SFTPGO_ACTION_STATUS`, integer. 0 means an error occurred. 1 means no error
 
 
 Previous global environment variables aren't cleared when the script is called.
 Previous global environment variables aren't cleared when the script is called.
-The `command` must finish within 30 seconds.
+The program must finish within 30 seconds.
 
 
-The `http_notification_url`, if defined, will be invoked as HTTP POST. The request body will contain a JSON serialized struct with the following fields:
+If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. The request body will contain a JSON serialized struct with the following fields:
 
 
 - `action`
 - `action`
 - `username`
 - `username`
@@ -49,7 +50,7 @@ The `actions` struct inside the "data_provider" configuration section allows you
 
 
 Actions will not be fired for internal updates, such as the last login or the user quota fields, or after external authentication.
 Actions will not be fired for internal updates, such as the last login or the user quota fields, or after external authentication.
 
 
-The `command`, if defined, 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: `add`, `update`, `delete`
 - `action`, string, possible values are: `add`, `update`, `delete`
 - `username`
 - `username`
@@ -60,7 +61,7 @@ The `command`, if defined, is invoked with the following arguments:
 - `uid`
 - `uid`
 - `gid`
 - `gid`
 
 
-The `command` can also read the following environment variables:
+The external program can also read the following environment variables:
 
 
 - `SFTPGO_USER_ACTION`
 - `SFTPGO_USER_ACTION`
 - `SFTPGO_USER_USERNAME`
 - `SFTPGO_USER_USERNAME`
@@ -79,8 +80,8 @@ The `command` can also read the following environment variables:
 - `SFTPGO_USER_FS_PROVIDER`
 - `SFTPGO_USER_FS_PROVIDER`
 
 
 Previous global environment variables aren't cleared when the script is called.
 Previous global environment variables aren't cleared when the script is called.
-The `command` must finish within 15 seconds.
+The program must finish within 15 seconds.
 
 
-The `http_notification_url`, if defined, will be invoked as HTTP POST. The action is added to the query string, for example `<http_notification_url>?action=update`, and the user is sent serialized as JSON inside the POST body with sensitive fields removed.
+If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. The action is added to the query string, for example `<hook>?action=update`, and the user is sent serialized as JSON inside the POST body with sensitive fields removed.
 
 
 The HTTP request will use the global configuration for HTTP clients.
 The HTTP request will use the global configuration for HTTP clients.

+ 14 - 9
sftpd/internal_test.go

@@ -159,9 +159,8 @@ func TestWrongActions(t *testing.T) {
 		badCommand = "C:\\bad\\command"
 		badCommand = "C:\\bad\\command"
 	}
 	}
 	actions = Actions{
 	actions = Actions{
-		ExecuteOn:           []string{operationDownload},
-		Command:             badCommand,
-		HTTPNotificationURL: "",
+		ExecuteOn: []string{operationDownload},
+		Hook:      badCommand,
 	}
 	}
 	user := dataprovider.User{
 	user := dataprovider.User{
 		Username: "username",
 		Username: "username",
@@ -170,21 +169,27 @@ func TestWrongActions(t *testing.T) {
 	assert.Error(t, err, "action with bad command must fail")
 	assert.Error(t, err, "action with bad command must fail")
 
 
 	err = executeAction(newActionNotification(user, operationDelete, "path", "", "", 0, nil))
 	err = executeAction(newActionNotification(user, operationDelete, "path", "", "", 0, nil))
-	assert.NoError(t, err)
-	actions.Command = ""
-	actions.HTTPNotificationURL = "http://foo\x7f.com/"
+	assert.EqualError(t, err, errUnconfiguredAction.Error())
+	actions.Hook = "http://foo\x7f.com/"
 	err = executeAction(newActionNotification(user, operationDownload, "path", "", "", 0, nil))
 	err = executeAction(newActionNotification(user, operationDownload, "path", "", "", 0, nil))
 	assert.Error(t, err, "action with bad url must fail")
 	assert.Error(t, err, "action with bad url must fail")
 
 
+	actions.Hook = ""
+	err = executeAction(newActionNotification(user, operationDownload, "path", "", "", 0, nil))
+	assert.Error(t, err, errNoHook.Error())
+
+	actions.Hook = "relative path"
+	err = executeNotificationCommand(newActionNotification(user, operationDownload, "path", "", "", 0, nil))
+	assert.EqualError(t, err, fmt.Sprintf("invalid notification command %#v", actions.Hook))
+
 	actions = actionsCopy
 	actions = actionsCopy
 }
 }
 
 
 func TestActionHTTP(t *testing.T) {
 func TestActionHTTP(t *testing.T) {
 	actionsCopy := actions
 	actionsCopy := actions
 	actions = Actions{
 	actions = Actions{
-		ExecuteOn:           []string{operationDownload},
-		Command:             "",
-		HTTPNotificationURL: "http://127.0.0.1:8080/",
+		ExecuteOn: []string{operationDownload},
+		Hook:      "http://127.0.0.1:8080/",
 	}
 	}
 	user := dataprovider.User{
 	user := dataprovider.User{
 		Username: "username",
 		Username: "username",

+ 27 - 22
sftpd/sftpd.go

@@ -7,11 +7,13 @@ import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
 	"encoding/json"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
 	"net/url"
 	"net/url"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"path/filepath"
 	"path/filepath"
+	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
@@ -66,9 +68,11 @@ 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"}
-	sshHashCommands    = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
-	systemCommands     = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
+	defaultSSHCommands    = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"}
+	sshHashCommands       = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
+	systemCommands        = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
+	errUnconfiguredAction = errors.New("no hook is configured for this action")
+	errNoHook             = errors.New("unable to execute action, no hook defined")
 )
 )
 
 
 type connectionTransfer struct {
 type connectionTransfer struct {
@@ -92,10 +96,12 @@ type ActiveQuotaScan struct {
 type Actions struct {
 type Actions struct {
 	// Valid values are download, upload, delete, rename, ssh_cmd. Empty slice to disable
 	// Valid values are download, upload, delete, rename, ssh_cmd. Empty slice to disable
 	ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
 	ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
-	// Absolute path to the command to execute, empty to disable
+	// Deprecated: please use Hook
 	Command string `json:"command" mapstructure:"command"`
 	Command string `json:"command" mapstructure:"command"`
-	// The URL to notify using an HTTP GET, empty to disable
+	// Deprecated: please use Hook
 	HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
 	HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
+	// Absolute path to an external program or an HTTP URL
+	Hook string `json:"hook" mapstructure:"hook"`
 }
 }
 
 
 // ConnectionStatus status for an active connection
 // ConnectionStatus status for an active connection
@@ -474,38 +480,36 @@ func isAtomicUploadEnabled() bool {
 }
 }
 
 
 func executeNotificationCommand(a actionNotification) error {
 func executeNotificationCommand(a actionNotification) error {
+	if !filepath.IsAbs(actions.Hook) {
+		err := fmt.Errorf("invalid notification command %#v", actions.Hook)
+		logger.Warn(logSender, "", "unable to execute notification command: %v", err)
+		return err
+	}
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer cancel()
 	defer cancel()
-	cmd := exec.CommandContext(ctx, actions.Command, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd)
+	cmd := exec.CommandContext(ctx, actions.Hook, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd)
 	cmd.Env = append(os.Environ(), a.AsEnvVars()...)
 	cmd.Env = append(os.Environ(), a.AsEnvVars()...)
 	startTime := time.Now()
 	startTime := time.Now()
 	err := cmd.Run()
 	err := cmd.Run()
 	logger.Debug(logSender, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v",
 	logger.Debug(logSender, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v",
-		actions.Command, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd, time.Since(startTime), err)
+		actions.Hook, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd, time.Since(startTime), err)
 	return err
 	return err
 }
 }
 
 
 // executed in a goroutine
 // 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 nil
+		return errUnconfiguredAction
 	}
 	}
-	var err error
-	if len(actions.Command) > 0 && filepath.IsAbs(actions.Command) {
-		// we are in a goroutine but if we have to send an HTTP notification we don't want to wait for the
-		// end of the command
-		if len(actions.HTTPNotificationURL) > 0 {
-			go executeNotificationCommand(a) //nolint:errcheck
-		} else {
-			err = executeNotificationCommand(a) //nolint:errcheck
-		}
+	if len(actions.Hook) == 0 {
+		logger.Warn(logSender, "", "Unable to send notification, no hook is defined")
+		return errNoHook
 	}
 	}
-	if len(actions.HTTPNotificationURL) > 0 {
+	if strings.HasPrefix(actions.Hook, "http") {
 		var url *url.URL
 		var url *url.URL
-		url, err = url.Parse(actions.HTTPNotificationURL)
+		url, err := url.Parse(actions.Hook)
 		if err != nil {
 		if err != nil {
-			logger.Warn(logSender, "", "Invalid http_notification_url %#v for operation %#v: %v", actions.HTTPNotificationURL,
-				a.Action, err)
+			logger.Warn(logSender, "", "Invalid hook %#v for operation %#v: %v", actions.Hook, a.Action, err)
 			return err
 			return err
 		}
 		}
 		startTime := time.Now()
 		startTime := time.Now()
@@ -518,6 +522,7 @@ func executeAction(a actionNotification) error {
 		}
 		}
 		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)
+		return err
 	}
 	}
-	return err
+	return executeNotificationCommand(a)
 }
 }

+ 1 - 2
sftpd/sftpd_test.go

@@ -174,8 +174,7 @@ func TestMain(m *testing.M) {
 		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.Command = "/bin/true"
-		sftpdConf.Actions.HTTPNotificationURL = "http://127.0.0.1:8083/"
+		sftpdConf.Actions.Hook = "/bin/true"
 		scriptArgs = "$@"
 		scriptArgs = "$@"
 		scpPath, err = exec.LookPath("scp")
 		scpPath, err = exec.LookPath("scp")
 		if err != nil {
 		if err != nil {

+ 2 - 4
sftpgo.json

@@ -9,8 +9,7 @@
     "upload_mode": 0,
     "upload_mode": 0,
     "actions": {
     "actions": {
       "execute_on": [],
       "execute_on": [],
-      "command": "",
-      "http_notification_url": ""
+      "hook": ""
     },
     },
     "host_keys": [],
     "host_keys": [],
     "kex_algorithms": [],
     "kex_algorithms": [],
@@ -46,8 +45,7 @@
     "users_base_dir": "",
     "users_base_dir": "",
     "actions": {
     "actions": {
       "execute_on": [],
       "execute_on": [],
-      "command": "",
-      "http_notification_url": ""
+      "hook": ""
     },
     },
     "external_auth_hook": "",
     "external_auth_hook": "",
     "external_auth_scope": 0,
     "external_auth_scope": 0,