mirror of
https://github.com/drakkan/sftpgo.git
synced 2025-12-07 23:00:55 +03:00
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
This commit is contained in:
@@ -53,9 +53,8 @@ func init() {
|
|||||||
Umask: "0022",
|
Umask: "0022",
|
||||||
UploadMode: 0,
|
UploadMode: 0,
|
||||||
Actions: sftpd.Actions{
|
Actions: sftpd.Actions{
|
||||||
ExecuteOn: []string{},
|
ExecuteOn: []string{},
|
||||||
Command: "",
|
Hook: "",
|
||||||
HTTPNotificationURL: "",
|
|
||||||
},
|
},
|
||||||
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{},
|
ExecuteOn: []string{},
|
||||||
Command: "",
|
Hook: "",
|
||||||
HTTPNotificationURL: "",
|
|
||||||
},
|
},
|
||||||
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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Deprecated: please use Hook
|
||||||
// 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
|
|
||||||
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) {
|
if strings.HasPrefix(config.Actions.Hook, "http") {
|
||||||
// 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 {
|
|
||||||
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,
|
providerLog(logger.LevelWarn, "Invalid http_notification_url %#v for operation %#v: %v", config.Actions.Hook, operation, err)
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -159,9 +159,8 @@ func TestWrongActions(t *testing.T) {
|
|||||||
badCommand = "C:\\bad\\command"
|
badCommand = "C:\\bad\\command"
|
||||||
}
|
}
|
||||||
actions = Actions{
|
actions = Actions{
|
||||||
ExecuteOn: []string{operationDownload},
|
ExecuteOn: []string{operationDownload},
|
||||||
Command: badCommand,
|
Hook: badCommand,
|
||||||
HTTPNotificationURL: "",
|
|
||||||
}
|
}
|
||||||
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)
|
assert.EqualError(t, err, errUnconfiguredAction.Error())
|
||||||
actions.Command = ""
|
actions.Hook = "http://foo\x7f.com/"
|
||||||
actions.HTTPNotificationURL = "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},
|
ExecuteOn: []string{operationDownload},
|
||||||
Command: "",
|
Hook: "http://127.0.0.1:8080/",
|
||||||
HTTPNotificationURL: "http://127.0.0.1:8080/",
|
|
||||||
}
|
}
|
||||||
user := dataprovider.User{
|
user := dataprovider.User{
|
||||||
Username: "username",
|
Username: "username",
|
||||||
|
|||||||
@@ -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"}
|
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")
|
||||||
|
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.Hook) == 0 {
|
||||||
if len(actions.Command) > 0 && filepath.IsAbs(actions.Command) {
|
logger.Warn(logSender, "", "Unable to send notification, no hook is defined")
|
||||||
// we are in a goroutine but if we have to send an HTTP notification we don't want to wait for the
|
return errNoHook
|
||||||
// end of the command
|
|
||||||
if len(actions.HTTPNotificationURL) > 0 {
|
|
||||||
go executeNotificationCommand(a) //nolint:errcheck
|
|
||||||
} else {
|
|
||||||
err = executeNotificationCommand(a) //nolint:errcheck
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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,
|
logger.Warn(logSender, "", "Invalid hook %#v for operation %#v: %v", actions.Hook, a.Action, err)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Hook = "/bin/true"
|
||||||
sftpdConf.Actions.HTTPNotificationURL = "http://127.0.0.1:8083/"
|
|
||||||
scriptArgs = "$@"
|
scriptArgs = "$@"
|
||||||
scpPath, err = exec.LookPath("scp")
|
scpPath, err = exec.LookPath("scp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
"upload_mode": 0,
|
"upload_mode": 0,
|
||||||
"actions": {
|
"actions": {
|
||||||
"execute_on": [],
|
"execute_on": [],
|
||||||
"command": "",
|
"hook": ""
|
||||||
"http_notification_url": ""
|
|
||||||
},
|
},
|
||||||
"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": "",
|
"hook": ""
|
||||||
"http_notification_url": ""
|
|
||||||
},
|
},
|
||||||
"external_auth_hook": "",
|
"external_auth_hook": "",
|
||||||
"external_auth_scope": 0,
|
"external_auth_scope": 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user