From 751946f47a1e3b145e238f35008c8a250678677c Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Fri, 20 May 2022 19:30:54 +0200 Subject: [PATCH] allow to customize timeout and env vars for program based hooks Fixes #847 Signed-off-by: Nicola Murino --- .github/workflows/development.yml | 1 + cmd/startsubsys.go | 5 ++ command/command.go | 100 ++++++++++++++++++++++++++++ command/command_test.go | 105 ++++++++++++++++++++++++++++++ common/actions.go | 7 +- common/common.go | 17 +++-- common/dataretention.go | 6 +- config/config.go | 48 ++++++++++++++ config/config_test.go | 64 ++++++++++++++++++ dataprovider/actions.go | 7 +- dataprovider/dataprovider.go | 30 ++++++--- docs/full-configuration.md | 7 ++ go.mod | 2 +- go.sum | 4 +- service/service.go | 18 +++-- sftpgo.json | 5 ++ 16 files changed, 394 insertions(+), 32 deletions(-) create mode 100644 command/command.go create mode 100644 command/command_test.go diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index fb734257..106f27c8 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -96,6 +96,7 @@ jobs: go test -v -p 1 -timeout 5m ./webdavd -covermode=atomic go test -v -p 1 -timeout 2m ./telemetry -covermode=atomic go test -v -p 1 -timeout 2m ./mfa -covermode=atomic + go test -v -p 1 -timeout 2m ./command -covermode=atomic env: SFTPGO_DATA_PROVIDER__DRIVER: bolt SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db' diff --git a/cmd/startsubsys.go b/cmd/startsubsys.go index 1eda94c0..6d509762 100644 --- a/cmd/startsubsys.go +++ b/cmd/startsubsys.go @@ -111,6 +111,11 @@ Command-line flags should be specified in the Subsystem declaration. logger.Error(logSender, connectionID, "unable to initialize http client: %v", err) os.Exit(1) } + commandConfig := config.GetCommandConfig() + if err := commandConfig.Initialize(); err != nil { + logger.Error(logSender, connectionID, "unable to initialize commands configuration: %v", err) + os.Exit(1) + } user, err := dataprovider.UserExists(username) if err == nil { if user.HomeDir != filepath.Clean(homedir) && !preserveHomeDir { diff --git a/command/command.go b/command/command.go new file mode 100644 index 00000000..42db839d --- /dev/null +++ b/command/command.go @@ -0,0 +1,100 @@ +package command + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + minTimeout = 1 + maxTimeout = 300 + defaultTimeout = 30 +) + +var ( + config Config +) + +// Command define the configuration for a specific commands +type Command struct { + // Path is the command path as defined in the hook configuration + Path string `json:"path" mapstructure:"path"` + // Timeout specifies a time limit, in seconds, for the command execution. + // This value overrides the global timeout if set. + // 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. + // 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"` +} + +// Config defines the configuration for external commands such as +// program based hooks +type Config struct { + // Timeout specifies a global time limit, in seconds, for the external commands execution + Timeout int `json:"timeout" mapstructure:"timeout"` + // Env defines additional environment variable for the commands. + // Each entry is of the form "key=value". + // Do not use variables with the SFTPGO_ prefix to avoid conflicts with env + // vars that SFTPGo sets + Env []string `json:"env" mapstructure:"env"` + // Commands defines configuration for specific commands + Commands []Command `json:"commands" mapstructure:"commands"` +} + +func init() { + config = Config{ + Timeout: defaultTimeout, + } +} + +// Initialize configures commands +func (c Config) Initialize() error { + if c.Timeout < minTimeout || c.Timeout > maxTimeout { + return fmt.Errorf("invalid timeout %v", c.Timeout) + } + for _, env := range c.Env { + if len(strings.Split(env, "=")) != 2 { + return fmt.Errorf("invalid env var %#v", env) + } + } + for idx, cmd := range c.Commands { + if cmd.Path == "" { + return fmt.Errorf("invalid path %#v", cmd.Path) + } + if cmd.Timeout == 0 { + c.Commands[idx].Timeout = c.Timeout + } else { + if cmd.Timeout < minTimeout || cmd.Timeout > maxTimeout { + return fmt.Errorf("invalid timeout %v for command %#v", cmd.Timeout, cmd.Path) + } + } + for _, env := range cmd.Env { + if len(strings.Split(env, "=")) != 2 { + return fmt.Errorf("invalid env var %#v for command %#v", env, cmd.Path) + } + } + } + config = c + return nil +} + +// GetConfig returns the configuration for the specified command +func GetConfig(command string) (time.Duration, []string) { + env := os.Environ() + 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 + } + } + + return timeout, env +} diff --git a/command/command_test.go b/command/command_test.go new file mode 100644 index 00000000..47e89ec7 --- /dev/null +++ b/command/command_test.go @@ -0,0 +1,105 @@ +package command + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandConfig(t *testing.T) { + require.Equal(t, defaultTimeout, config.Timeout) + cfg := Config{ + Timeout: 10, + Env: []string{"a=b"}, + } + err := cfg.Initialize() + require.NoError(t, err) + assert.Equal(t, cfg.Timeout, config.Timeout) + assert.Equal(t, cfg.Env, config.Env) + assert.Len(t, cfg.Commands, 0) + timeout, env := GetConfig("cmd") + assert.Equal(t, time.Duration(config.Timeout)*time.Second, timeout) + assert.Contains(t, env, "a=b") + + cfg.Commands = []Command{ + { + Path: "cmd1", + Timeout: 30, + Env: []string{"c=d"}, + }, + { + Path: "cmd2", + Timeout: 0, + Env: []string{"e=f"}, + }, + } + err = cfg.Initialize() + require.NoError(t, err) + assert.Equal(t, cfg.Timeout, config.Timeout) + assert.Equal(t, cfg.Env, config.Env) + if assert.Len(t, config.Commands, 2) { + 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[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) + } + timeout, env = 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") + 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") +} + +func TestConfigErrors(t *testing.T) { + c := Config{} + err := c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid timeout") + } + c.Timeout = 10 + c.Env = []string{"a"} + err = c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid env var") + } + c.Env = nil + c.Commands = []Command{ + { + Path: "", + }, + } + err = c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid path") + } + c.Commands = []Command{ + { + Path: "path", + Timeout: 10000, + }, + } + err = c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid timeout") + } + c.Commands = []Command{ + { + Path: "path", + Timeout: 30, + Env: []string{"b"}, + }, + } + err = c.Initialize() + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid env var") + } +} diff --git a/common/actions.go b/common/actions.go index c1aa8976..29a9766e 100644 --- a/common/actions.go +++ b/common/actions.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "net/url" - "os" "os/exec" "path/filepath" "strings" @@ -17,6 +16,7 @@ import ( "github.com/sftpgo/sdk" "github.com/sftpgo/sdk/plugin/notifier" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/logger" @@ -223,11 +223,12 @@ func (h *defaultActionHandler) handleCommand(event *notifier.FsEvent) error { return err } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + timeout, env := command.GetConfig(Config.Actions.Hook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, Config.Actions.Hook) - cmd.Env = append(os.Environ(), notificationAsEnvVars(event)...) + cmd.Env = append(env, notificationAsEnvVars(event)...) startTime := time.Now() err := cmd.Run() diff --git a/common/common.go b/common/common.go index ff35057d..196e1203 100644 --- a/common/common.go +++ b/common/common.go @@ -19,6 +19,7 @@ import ( "github.com/pires/go-proxyproto" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/logger" @@ -577,9 +578,12 @@ func (c *Configuration) ExecuteStartupHook() error { return err } startTime := time.Now() - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + timeout, env := command.GetConfig(c.StartupHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, c.StartupHook) + cmd.Env = env err := cmd.Run() logger.Debug(logSender, "", "Startup hook executed, elapsed: %v, error: %v", time.Since(startTime), err) return nil @@ -617,12 +621,13 @@ func (c *Configuration) executePostDisconnectHook(remoteAddr, protocol, username logger.Debug(protocol, connID, "invalid post disconnect hook %#v", c.PostDisconnectHook) return } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + timeout, env := command.GetConfig(c.PostDisconnectHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() startTime := time.Now() cmd := exec.CommandContext(ctx, c.PostDisconnectHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_CONNECTION_IP=%v", ipAddr), fmt.Sprintf("SFTPGO_CONNECTION_USERNAME=%v", username), fmt.Sprintf("SFTPGO_CONNECTION_DURATION=%v", connDuration), @@ -676,10 +681,12 @@ func (c *Configuration) ExecutePostConnectHook(ipAddr, protocol string) error { logger.Warn(protocol, "", "Login from ip %#v denied: %v", ipAddr, err) return err } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + timeout, env := command.GetConfig(c.PostConnectHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, c.PostConnectHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_CONNECTION_IP=%v", ipAddr), fmt.Sprintf("SFTPGO_CONNECTION_PROTOCOL=%v", protocol)) err := cmd.Run() diff --git a/common/dataretention.go b/common/dataretention.go index ddfa1c5c..8fc36001 100644 --- a/common/dataretention.go +++ b/common/dataretention.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/logger" @@ -450,11 +451,12 @@ func (c *RetentionCheck) sendHookNotification(elapsed time.Duration, errCheck er c.conn.Log(logger.LevelError, "%v", err) return err } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + timeout, env := command.GetConfig(Config.DataRetentionHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, Config.DataRetentionHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_DATA_RETENTION_RESULT=%v", string(jsonData))) err := cmd.Run() diff --git a/config/config.go b/config/config.go index f00294e8..9166b34e 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/viper" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/ftpd" @@ -139,6 +140,7 @@ type globalConfig struct { ProviderConf dataprovider.Config `json:"data_provider" mapstructure:"data_provider"` HTTPDConfig httpd.Conf `json:"httpd" mapstructure:"httpd"` HTTPConfig httpclient.Config `json:"http" mapstructure:"http"` + CommandConfig command.Config `json:"command" mapstructure:"command"` KMSConfig kms.Configuration `json:"kms" mapstructure:"kms"` MFAConfig mfa.Config `json:"mfa" mapstructure:"mfa"` TelemetryConfig telemetry.Conf `json:"telemetry" mapstructure:"telemetry"` @@ -353,6 +355,11 @@ func Init() { SkipTLSVerify: false, Headers: nil, }, + CommandConfig: command.Config{ + Timeout: 30, + Env: nil, + Commands: nil, + }, KMSConfig: kms.Configuration{ Secrets: kms.Secrets{ URL: "", @@ -461,6 +468,11 @@ func GetHTTPConfig() httpclient.Config { return globalConf.HTTPConfig } +// GetCommandConfig returns the configuration for external commands +func GetCommandConfig() command.Config { + return globalConf.CommandConfig +} + // GetKMSConfig returns the KMS configuration func GetKMSConfig() kms.Configuration { return globalConf.KMSConfig @@ -674,6 +686,7 @@ func loadBindingsFromEnv() { getHTTPDBindingFromEnv(idx) getHTTPClientCertificatesFromEnv(idx) getHTTPClientHeadersFromEnv(idx) + getCommandConfigsFromEnv(idx) } } @@ -1546,6 +1559,9 @@ func getHTTPClientCertificatesFromEnv(idx int) { func getHTTPClientHeadersFromEnv(idx int) { header := httpclient.Header{} + if len(globalConf.HTTPConfig.Headers) > idx { + header = globalConf.HTTPConfig.Headers[idx] + } key, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_HTTP__HEADERS__%v__KEY", idx)) if ok { @@ -1571,6 +1587,36 @@ func getHTTPClientHeadersFromEnv(idx int) { } } +func getCommandConfigsFromEnv(idx int) { + cfg := command.Command{} + if len(globalConf.CommandConfig.Commands) > idx { + cfg = globalConf.CommandConfig.Commands[idx] + } + + path, ok := os.LookupEnv(fmt.Sprintf("SFTPGO_COMMAND__COMMANDS__%v__PATH", idx)) + if ok { + cfg.Path = path + } + + timeout, ok := lookupIntFromEnv(fmt.Sprintf("SFTPGO_COMMAND__COMMANDS__%v__TIMEOUT", idx)) + if ok { + cfg.Timeout = int(timeout) + } + + env, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_COMMAND__COMMANDS__%v__ENV", idx)) + if ok { + cfg.Env = env + } + + if cfg.Path != "" { + if len(globalConf.CommandConfig.Commands) > idx { + globalConf.CommandConfig.Commands[idx] = cfg + } else { + globalConf.CommandConfig.Commands = append(globalConf.CommandConfig.Commands, cfg) + } + } +} + func setViperDefaults() { viper.SetDefault("common.idle_timeout", globalConf.Common.IdleTimeout) viper.SetDefault("common.upload_mode", globalConf.Common.UploadMode) @@ -1714,6 +1760,8 @@ func setViperDefaults() { viper.SetDefault("http.retry_max", globalConf.HTTPConfig.RetryMax) viper.SetDefault("http.ca_certificates", globalConf.HTTPConfig.CACertificates) viper.SetDefault("http.skip_tls_verify", globalConf.HTTPConfig.SkipTLSVerify) + viper.SetDefault("command.timeout", globalConf.CommandConfig.Timeout) + viper.SetDefault("command.env", globalConf.CommandConfig.Env) viper.SetDefault("kms.secrets.url", globalConf.KMSConfig.Secrets.URL) viper.SetDefault("kms.secrets.master_key", globalConf.KMSConfig.Secrets.MasterKeyString) viper.SetDefault("kms.secrets.master_key_path", globalConf.KMSConfig.Secrets.MasterKeyPath) diff --git a/config/config_test.go b/config/config_test.go index b2253ff7..c0e15709 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/common" "github.com/drakkan/sftpgo/v2/config" "github.com/drakkan/sftpgo/v2/dataprovider" @@ -679,6 +680,69 @@ func TestSFTPDBindingsFromEnv(t *testing.T) { require.True(t, bindings[1].ApplyProxyConfig) // default value } +func TestCommandsFromEnv(t *testing.T) { + reset() + + configDir := ".." + confName := tempConfigName + ".json" + configFilePath := filepath.Join(configDir, confName) + err := config.LoadConfig(configDir, "") + assert.NoError(t, err) + commandConfig := config.GetCommandConfig() + commandConfig.Commands = append(commandConfig.Commands, command.Command{ + Path: "cmd", + Timeout: 10, + Env: []string{"a=a"}, + }) + c := make(map[string]command.Config) + c["command"] = commandConfig + jsonConf, err := json.Marshal(c) + require.NoError(t, err) + err = os.WriteFile(configFilePath, jsonConf, os.ModePerm) + require.NoError(t, err) + err = config.LoadConfig(configDir, confName) + require.NoError(t, err) + commandConfig = config.GetCommandConfig() + require.Equal(t, 30, commandConfig.Timeout) + require.Len(t, commandConfig.Env, 0) + require.Len(t, commandConfig.Commands, 1) + require.Equal(t, "cmd", commandConfig.Commands[0].Path) + require.Equal(t, 10, commandConfig.Commands[0].Timeout) + require.Equal(t, []string{"a=a"}, commandConfig.Commands[0].Env) + + os.Setenv("SFTPGO_COMMAND__TIMEOUT", "25") + os.Setenv("SFTPGO_COMMAND__ENV", "a=b,c=d") + os.Setenv("SFTPGO_COMMAND__COMMANDS__0__PATH", "cmd1") + os.Setenv("SFTPGO_COMMAND__COMMANDS__0__TIMEOUT", "11") + os.Setenv("SFTPGO_COMMAND__COMMANDS__1__PATH", "cmd2") + os.Setenv("SFTPGO_COMMAND__COMMANDS__1__TIMEOUT", "20") + os.Setenv("SFTPGO_COMMAND__COMMANDS__1__ENV", "e=f") + + t.Cleanup(func() { + os.Unsetenv("SFTPGO_COMMAND__TIMEOUT") + os.Unsetenv("SFTPGO_COMMAND__ENV") + os.Unsetenv("SFTPGO_COMMAND__COMMANDS__0__PATH") + os.Unsetenv("SFTPGO_COMMAND__COMMANDS__0__TIMEOUT") + os.Unsetenv("SFTPGO_COMMAND__COMMANDS__0__ENV") + }) + + err = config.LoadConfig(configDir, confName) + assert.NoError(t, err) + commandConfig = config.GetCommandConfig() + require.Equal(t, 25, commandConfig.Timeout) + require.Equal(t, []string{"a=b", "c=d"}, commandConfig.Env) + require.Len(t, commandConfig.Commands, 2) + require.Equal(t, "cmd1", commandConfig.Commands[0].Path) + require.Equal(t, 11, commandConfig.Commands[0].Timeout) + require.Equal(t, []string{"a=a"}, commandConfig.Commands[0].Env) + require.Equal(t, "cmd2", commandConfig.Commands[1].Path) + require.Equal(t, 20, commandConfig.Commands[1].Timeout) + require.Equal(t, []string{"e=f"}, commandConfig.Commands[1].Env) + + err = os.Remove(configFilePath) + assert.NoError(t, err) +} + func TestFTPDBindingsFromEnv(t *testing.T) { reset() diff --git a/dataprovider/actions.go b/dataprovider/actions.go index 04c08d37..3ca36099 100644 --- a/dataprovider/actions.go +++ b/dataprovider/actions.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "net/url" - "os" "os/exec" "path/filepath" "strings" @@ -13,6 +12,7 @@ import ( "github.com/sftpgo/sdk/plugin/notifier" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/logger" "github.com/drakkan/sftpgo/v2/plugin" @@ -98,11 +98,12 @@ func executeNotificationCommand(operation, executor, ip, objectType, objectName return err } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + timeout, env := command.GetConfig(config.Actions.Hook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, config.Actions.Hook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_PROVIDER_ACTION=%v", operation), fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_TYPE=%v", objectType), fmt.Sprintf("SFTPGO_PROVIDER_OBJECT_NAME=%v", objectName), diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 649d93fb..99299a64 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -47,6 +47,7 @@ import ( "golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/ssh" + "github.com/drakkan/sftpgo/v2/command" "github.com/drakkan/sftpgo/v2/httpclient" "github.com/drakkan/sftpgo/v2/kms" "github.com/drakkan/sftpgo/v2/logger" @@ -3029,10 +3030,12 @@ func handleProgramInteractiveQuestions(client ssh.KeyboardInteractiveChallenge, func executeKeyboardInteractiveProgram(user *User, authHook string, client ssh.KeyboardInteractiveChallenge, ip, protocol string) (int, error) { authResult := 0 - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + timeout, env := command.GetConfig(authHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, authHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", user.Username), fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip), fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", user.Password)) @@ -3160,10 +3163,12 @@ func getPasswordHookResponse(username, password, ip, protocol string) ([]byte, e } return io.ReadAll(io.LimitReader(resp.Body, maxHookResponseSize)) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + timeout, env := command.GetConfig(config.CheckPasswordHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, config.CheckPasswordHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username), fmt.Sprintf("SFTPGO_AUTHD_PASSWORD=%v", password), fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip), @@ -3219,10 +3224,12 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte } return io.ReadAll(io.LimitReader(resp.Body, maxHookResponseSize)) } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + timeout, env := command.GetConfig(config.PreLoginHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, config.PreLoginHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod), fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip), @@ -3352,10 +3359,12 @@ func ExecutePostLoginHook(user *User, loginMethod, ip, protocol string, err erro user.Username, ip, protocol, respCode, time.Since(startTime), err) return } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + timeout, env := command.GetConfig(config.PostLoginHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + cmd := exec.CommandContext(ctx, config.PostLoginHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_LOGIND_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_LOGIND_IP=%v", ip), fmt.Sprintf("SFTPGO_LOGIND_METHOD=%v", loginMethod), @@ -3418,11 +3427,12 @@ func getExternalAuthResponse(username, password, pkey, keyboardInteractive, ip, return nil, fmt.Errorf("unable to serialize user as JSON: %w", err) } } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + timeout, env := command.GetConfig(config.ExternalAuthHook) + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, config.ExternalAuthHook) - cmd.Env = append(os.Environ(), + cmd.Env = append(env, fmt.Sprintf("SFTPGO_AUTHD_USERNAME=%v", username), fmt.Sprintf("SFTPGO_AUTHD_USER=%v", string(userAsJSON)), fmt.Sprintf("SFTPGO_AUTHD_IP=%v", ip), diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 5e14b5a5..94254245 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -325,6 +325,13 @@ The configuration file contains the following sections: - `key`, string - `value`, string. The header is silently ignored if `key` or `value` are empty - `url`, string, optional. If not empty, the header will be added only if the request URL starts with the one specified here +- **command**, configuration for external commands such as program based hooks + - `timeout`, integer. Timeout specifies a time limit, in seconds, to execute external commands. Valid range: `1-300`. Default: `30` + - `env`, list of strings. Additional environment variable to pass to all the external commands. Each entry is of the form `key=value`. Default: empty + - `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 - **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 1696879a..a54fb0b9 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,7 @@ require ( golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 golang.org/x/net v0.0.0-20220513224357-95641704303c golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 - golang.org/x/sys v0.0.0-20220519141025-dcacdad47464 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a golang.org/x/time v0.0.0-20220411224347-583f2d630306 google.golang.org/api v0.80.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 diff --git a/go.sum b/go.sum index 22e60a58..768b75fb 100644 --- a/go.sum +++ b/go.sum @@ -951,8 +951,8 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220519141025-dcacdad47464 h1:MpIuURY70f0iKp/oooEFtB2oENcHITo/z1b6u41pKCw= -golang.org/x/sys v0.0.0-20220519141025-dcacdad47464/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/service/service.go b/service/service.go index cbe06d6d..321a15dd 100644 --- a/service/service.go +++ b/service/service.go @@ -141,12 +141,6 @@ func (s *Service) Start(disableAWSInstallationCode bool) error { return err } - err = s.LoadInitialData() - if err != nil { - logger.Error(logSender, "", "unable to load initial data: %v", err) - logger.ErrorToConsole("unable to load initial data: %v", err) - } - httpConfig := config.GetHTTPConfig() err = httpConfig.Initialize(s.ConfigDir) if err != nil { @@ -154,6 +148,12 @@ func (s *Service) Start(disableAWSInstallationCode bool) error { logger.ErrorToConsole("error initializing http client: %v", err) return err } + commandConfig := config.GetCommandConfig() + if err := commandConfig.Initialize(); err != nil { + logger.Error(logSender, "", "error initializing commands configuration: %v", err) + logger.ErrorToConsole("error initializing commands configuration: %v", err) + return err + } s.startServices() go common.Config.ExecuteStartupHook() //nolint:errcheck @@ -162,6 +162,12 @@ func (s *Service) Start(disableAWSInstallationCode bool) error { } func (s *Service) startServices() { + err := s.LoadInitialData() + if err != nil { + logger.Error(logSender, "", "unable to load initial data: %v", err) + logger.ErrorToConsole("unable to load initial data: %v", err) + } + sftpdConf := config.GetSFTPDConfig() ftpdConf := config.GetFTPDConfig() httpdConf := config.GetHTTPDConfig() diff --git a/sftpgo.json b/sftpgo.json index 394c5f8c..8890234f 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -326,6 +326,11 @@ "skip_tls_verify": false, "headers": [] }, + "command": { + "timeout": 30, + "env": [], + "commands": [] + }, "kms": { "secrets": { "url": "",