Bläddra i källkod

allow to customize timeout and env vars for program based hooks

Fixes #847

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 år sedan
förälder
incheckning
751946f47a

+ 1 - 0
.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'

+ 5 - 0
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 {

+ 100 - 0
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
+}

+ 105 - 0
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")
+	}
+}

+ 4 - 3
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()

+ 12 - 5
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()

+ 4 - 2
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()
 

+ 48 - 0
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)

+ 64 - 0
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()
 

+ 4 - 3
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),

+ 20 - 10
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),

+ 7 - 0
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.

+ 1 - 1
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

+ 2 - 2
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=

+ 12 - 6
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()

+ 5 - 0
sftpgo.json

@@ -326,6 +326,11 @@
     "skip_tls_verify": false,
     "headers": []
   },
+  "command": {
+    "timeout": 30,
+    "env": [],
+    "commands": []
+  },
   "kms": {
     "secrets": {
       "url": "",