diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 187e44dd..03d1a24c 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -367,7 +367,9 @@ The configuration file contains the following sections: - `commands`, list of structs. Allow to customize configuration per-command. Each struct has the following fields: - `path`, string. Define the command path as defined in the hook configuration - `timeout`, integer. This value overrides the global timeout if set - - `env`, list of strings. These values are added to the environment variables defined for all commands, if any + - `env`, list of strings. These values are added to the environment variables defined for all commands, if any. Default: empty + - `args`, list of strings. Arguments to pass to the command identified by `path`. Default: empty + - `hook`, string. If not empty this configuration only apply to the specified hook name. Supported hook names: `fs_actions`, `provider_actions`, `startup`, `post_connect`, `post_disconnect`, `data_retention`, `check_password`, `pre_login`, `post_login`, `external_auth`, `keyboard_interactive`. Default: empty - **kms**, configuration for the Key Management Service, more details can be found [here](./kms.md) - `secrets` - `url`, string. Defines the URI to the KMS service. Default: blank. diff --git a/go.mod b/go.mod index 0869d0f3..2ca6da8b 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,7 @@ require ( golang.org/x/net v0.0.0-20220909164309-bea034e7d591 golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 - golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 + golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45 google.golang.org/api v0.96.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) diff --git a/go.sum b/go.sum index 0376d095..389a6d33 100644 --- a/go.sum +++ b/go.sum @@ -1000,8 +1000,8 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= -golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45 h1:yuLAip3bfURHClMG9VBdzPrQvCWjWiWUTBGV+/fCbUs= +golang.org/x/time v0.0.0-20220920022843-2ce7c2934d45/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/internal/command/command.go b/internal/command/command.go index 78c2d739..016ede35 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -20,6 +20,8 @@ import ( "os" "strings" "time" + + "github.com/drakkan/sftpgo/v2/internal/util" ) const ( @@ -28,8 +30,25 @@ const ( defaultTimeout = 30 ) +// Supported hook names +const ( + HookFsActions = "fs_actions" + HookProviderActions = "provider_actions" + HookStartup = "startup" + HookPostConnect = "post_connect" + HookPostDisconnect = "post_disconnect" + HookDataRetention = "data_retention" + HookCheckPassword = "check_password" + HookPreLogin = "pre_login" + HookPostLogin = "post_login" + HookExternalAuth = "external_auth" + HookKeyboardInteractive = "keyboard_interactive" +) + var ( - config Config + config Config + supportedHooks = []string{HookFsActions, HookProviderActions, HookStartup, HookPostConnect, HookPostDisconnect, + HookDataRetention, HookCheckPassword, HookPreLogin, HookPostLogin, HookExternalAuth, HookKeyboardInteractive} ) // Command define the configuration for a specific commands @@ -41,10 +60,14 @@ type Command struct { // Do not use variables with the SFTPGO_ prefix to avoid conflicts with env // vars that SFTPGo sets Timeout int `json:"timeout" mapstructure:"timeout"` - // Env defines additional environment variable for the commands. + // Env defines additional environment variable for the command. // Each entry is of the form "key=value". // These values are added to the global environment variables if any Env []string `json:"env" mapstructure:"env"` + // Args defines arguments to pass to the specified command + Args []string `json:"args" mapstructure:"args"` + // if not empty both command path and hook name must match + Hook string `json:"hook" mapstructure:"hook"` } // Config defines the configuration for external commands such as @@ -93,23 +116,33 @@ func (c Config) Initialize() error { return fmt.Errorf("invalid env var %#v for command %#v", env, cmd.Path) } } + // don't validate args, we allow to pass empty arguments + if cmd.Hook != "" { + if !util.Contains(supportedHooks, cmd.Hook) { + return fmt.Errorf("invalid hook name %q, supported values: %+v", cmd.Hook, supportedHooks) + } + } } config = c return nil } // GetConfig returns the configuration for the specified command -func GetConfig(command string) (time.Duration, []string) { +func GetConfig(command, hook string) (time.Duration, []string, []string) { env := os.Environ() + var args []string timeout := time.Duration(config.Timeout) * time.Second env = append(env, config.Env...) for _, cmd := range config.Commands { if cmd.Path == command { - timeout = time.Duration(cmd.Timeout) * time.Second - env = append(env, cmd.Env...) - break + if cmd.Hook == "" || cmd.Hook == hook { + timeout = time.Duration(cmd.Timeout) * time.Second + env = append(env, cmd.Env...) + args = cmd.Args + break + } } } - return timeout, env + return timeout, env, args } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 7f0a5838..fed12c75 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -33,15 +33,17 @@ func TestCommandConfig(t *testing.T) { assert.Equal(t, cfg.Timeout, config.Timeout) assert.Equal(t, cfg.Env, config.Env) assert.Len(t, cfg.Commands, 0) - timeout, env := GetConfig("cmd") + timeout, env, args := GetConfig("cmd", "") assert.Equal(t, time.Duration(config.Timeout)*time.Second, timeout) assert.Contains(t, env, "a=b") + assert.Len(t, args, 0) cfg.Commands = []Command{ { Path: "cmd1", Timeout: 30, Env: []string{"c=d"}, + Args: []string{"1", "", "2"}, }, { Path: "cmd2", @@ -57,20 +59,68 @@ func TestCommandConfig(t *testing.T) { assert.Equal(t, cfg.Commands[0].Path, config.Commands[0].Path) assert.Equal(t, cfg.Commands[0].Timeout, config.Commands[0].Timeout) assert.Equal(t, cfg.Commands[0].Env, config.Commands[0].Env) + assert.Equal(t, cfg.Commands[0].Args, config.Commands[0].Args) assert.Equal(t, cfg.Commands[1].Path, config.Commands[1].Path) assert.Equal(t, cfg.Timeout, config.Commands[1].Timeout) assert.Equal(t, cfg.Commands[1].Env, config.Commands[1].Env) + assert.Equal(t, cfg.Commands[1].Args, config.Commands[1].Args) } - timeout, env = GetConfig("cmd1") + timeout, env, args = GetConfig("cmd1", "") assert.Equal(t, time.Duration(config.Commands[0].Timeout)*time.Second, timeout) assert.Contains(t, env, "a=b") assert.Contains(t, env, "c=d") assert.NotContains(t, env, "e=f") - timeout, env = GetConfig("cmd2") + if assert.Len(t, args, 3) { + assert.Equal(t, "1", args[0]) + assert.Empty(t, args[1]) + assert.Equal(t, "2", args[2]) + } + timeout, env, args = GetConfig("cmd2", "") assert.Equal(t, time.Duration(config.Timeout)*time.Second, timeout) assert.Contains(t, env, "a=b") assert.NotContains(t, env, "c=d") assert.Contains(t, env, "e=f") + assert.Len(t, args, 0) + + cfg.Commands = []Command{ + { + Path: "cmd1", + Timeout: 30, + Env: []string{"c=d"}, + Args: []string{"1", "", "2"}, + Hook: HookCheckPassword, + }, + { + Path: "cmd1", + Timeout: 0, + Env: []string{"e=f"}, + Hook: HookExternalAuth, + }, + } + err = cfg.Initialize() + require.NoError(t, err) + timeout, env, args = GetConfig("cmd1", "") + assert.Equal(t, time.Duration(config.Timeout)*time.Second, timeout) + assert.Contains(t, env, "a=b") + assert.NotContains(t, env, "c=d") + assert.NotContains(t, env, "e=f") + assert.Len(t, args, 0) + timeout, env, args = GetConfig("cmd1", HookCheckPassword) + assert.Equal(t, time.Duration(config.Commands[0].Timeout)*time.Second, timeout) + assert.Contains(t, env, "a=b") + assert.Contains(t, env, "c=d") + assert.NotContains(t, env, "e=f") + if assert.Len(t, args, 3) { + assert.Equal(t, "1", args[0]) + assert.Empty(t, args[1]) + assert.Equal(t, "2", args[2]) + } + timeout, env, args = GetConfig("cmd1", HookExternalAuth) + assert.Equal(t, time.Duration(cfg.Timeout)*time.Second, timeout) + assert.Contains(t, env, "a=b") + assert.NotContains(t, env, "c=d") + assert.Contains(t, env, "e=f") + assert.Len(t, args, 0) } func TestConfigErrors(t *testing.T) { @@ -116,4 +166,16 @@ func TestConfigErrors(t *testing.T) { if assert.Error(t, err) { assert.Contains(t, err.Error(), "invalid env var") } + c.Commands = []Command{ + { + Path: "path", + Timeout: 30, + Env: []string{"a=b"}, + Hook: "invali", + }, + } + err = c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid hook name") + } } diff --git a/internal/common/actions.go b/internal/common/actions.go index a7d86a56..f8bb5f03 100644 --- a/internal/common/actions.go +++ b/internal/common/actions.go @@ -272,11 +272,11 @@ func (h *defaultActionHandler) handleCommand(event *notifier.FsEvent) error { return err } - timeout, env := command.GetConfig(Config.Actions.Hook) + timeout, env, args := command.GetConfig(Config.Actions.Hook, command.HookFsActions) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, Config.Actions.Hook) + cmd := exec.CommandContext(ctx, Config.Actions.Hook, args...) cmd.Env = append(env, notificationAsEnvVars(event)...) startTime := time.Now() diff --git a/internal/common/common.go b/internal/common/common.go index d71128a0..72536f06 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -578,11 +578,11 @@ func (c *Configuration) ExecuteStartupHook() error { return err } startTime := time.Now() - timeout, env := command.GetConfig(c.StartupHook) + timeout, env, args := command.GetConfig(c.StartupHook, command.HookStartup) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, c.StartupHook) + cmd := exec.CommandContext(ctx, c.StartupHook, args...) cmd.Env = env err := cmd.Run() logger.Debug(logSender, "", "Startup hook executed, elapsed: %v, error: %v", time.Since(startTime), err) @@ -624,12 +624,12 @@ func (c *Configuration) executePostDisconnectHook(remoteAddr, protocol, username logger.Debug(protocol, connID, "invalid post disconnect hook %#v", c.PostDisconnectHook) return } - timeout, env := command.GetConfig(c.PostDisconnectHook) + timeout, env, args := command.GetConfig(c.PostDisconnectHook, command.HookPostDisconnect) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() startTime := time.Now() - cmd := exec.CommandContext(ctx, c.PostDisconnectHook) + cmd := exec.CommandContext(ctx, c.PostDisconnectHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_CONNECTION_IP=%v", ipAddr), fmt.Sprintf("SFTPGO_CONNECTION_USERNAME=%v", username), @@ -684,11 +684,11 @@ func (c *Configuration) ExecutePostConnectHook(ipAddr, protocol string) error { logger.Warn(protocol, "", "Login from ip %#v denied: %v", ipAddr, err) return err } - timeout, env := command.GetConfig(c.PostConnectHook) + timeout, env, args := command.GetConfig(c.PostConnectHook, command.HookPostConnect) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, c.PostConnectHook) + cmd := exec.CommandContext(ctx, c.PostConnectHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_CONNECTION_IP=%v", ipAddr), fmt.Sprintf("SFTPGO_CONNECTION_PROTOCOL=%v", protocol)) diff --git a/internal/common/dataretention.go b/internal/common/dataretention.go index 7b2fe17d..bde900cc 100644 --- a/internal/common/dataretention.go +++ b/internal/common/dataretention.go @@ -448,11 +448,11 @@ func (c *RetentionCheck) sendHookNotification(elapsed time.Duration, errCheck er c.conn.Log(logger.LevelError, "%v", err) return err } - timeout, env := command.GetConfig(Config.DataRetentionHook) + timeout, env, args := command.GetConfig(Config.DataRetentionHook, command.HookDataRetention) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, Config.DataRetentionHook) + cmd := exec.CommandContext(ctx, Config.DataRetentionHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_DATA_RETENTION_RESULT=%v", string(jsonData))) err := cmd.Run() diff --git a/internal/dataprovider/actions.go b/internal/dataprovider/actions.go index e74d3b8c..91452f64 100644 --- a/internal/dataprovider/actions.go +++ b/internal/dataprovider/actions.go @@ -128,11 +128,11 @@ func executeNotificationCommand(operation, executor, ip, objectType, objectName return err } - timeout, env := command.GetConfig(config.Actions.Hook) + timeout, env, args := command.GetConfig(config.Actions.Hook, command.HookProviderActions) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, config.Actions.Hook) + cmd := exec.CommandContext(ctx, config.Actions.Hook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%vs", operation), fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_TYPE=%s", objectType), diff --git a/internal/dataprovider/dataprovider.go b/internal/dataprovider/dataprovider.go index 7245deaa..78a476af 100644 --- a/internal/dataprovider/dataprovider.go +++ b/internal/dataprovider/dataprovider.go @@ -3329,11 +3329,11 @@ func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, func executeKeyboardInteractiveProgram(user *User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) { authResult := 0 - timeout, env := command.GetConfig(authHook) + timeout, env, args := command.GetConfig(authHook, command.HookKeyboardInteractive) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, authHook) + cmd := exec.CommandContext(ctx, authHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", user.Username), fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip), @@ -3462,11 +3462,11 @@ func getPasswordHookResponse(username, password, ip, protocol string) ([]byte, e } return io.ReadAll(io.LimitReader(resp.Body, maxHookResponseSize)) } - timeout, env := command.GetConfig(config.CheckPasswordHook) + timeout, env, args := command.GetConfig(config.CheckPasswordHook, command.HookCheckPassword) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, config.CheckPasswordHook) + cmd := exec.CommandContext(ctx, config.CheckPasswordHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username), fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password), @@ -3523,11 +3523,11 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte } return io.ReadAll(io.LimitReader(resp.Body, maxHookResponseSize)) } - timeout, env := command.GetConfig(config.PreLoginHook) + timeout, env, args := command.GetConfig(config.PreLoginHook, command.HookPreLogin) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, config.PreLoginHook) + cmd := exec.CommandContext(ctx, config.PreLoginHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod), @@ -3667,11 +3667,11 @@ func ExecutePostLoginHook(user *User, loginMethod, ip, protocol string, err erro user.Username, ip, protocol, respCode, time.Since(startTime), err) return } - timeout, env := command.GetConfig(config.PostLoginHook) + timeout, env, args := command.GetConfig(config.PostLoginHook, command.HookPostLogin) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, config.PostLoginHook) + cmd := exec.CommandContext(ctx, config.PostLoginHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip), @@ -3735,11 +3735,11 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, return nil, fmt.Errorf("unable to serialize user as JSON: %w", err) } } - timeout, env := command.GetConfig(config.ExternalAuthHook) + timeout, env, args := command.GetConfig(config.ExternalAuthHook, command.HookExternalAuth) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - cmd := exec.CommandContext(ctx, config.ExternalAuthHook) + cmd := exec.CommandContext(ctx, config.ExternalAuthHook, args...) cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username), fmt.Sprintf("SFTPGO_AUTHD_USER=%v", string(userAsJSON)),