From 22338ed478d7293235d26dba6dfea06bab448a9a Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Thu, 30 Jul 2020 22:33:49 +0200 Subject: [PATCH] add post connect hook Fixes #144 --- common/common.go | 64 ++++++++++++++++++++++- common/common_test.go | 43 ++++++++++++++++ docs/custom-actions.md | 2 +- docs/full-configuration.md | 11 ++-- docs/post-connect-hook.md | 25 +++++++++ ftpd/ftpd_test.go | 82 ++++++++++++++++++++++++++---- ftpd/server.go | 3 ++ sftpd/server.go | 4 ++ sftpd/sftpd_test.go | 101 +++++++++++++++++++++++++++++-------- sftpgo.json | 3 +- 10 files changed, 297 insertions(+), 41 deletions(-) create mode 100644 docs/post-connect-hook.md diff --git a/common/common.go b/common/common.go index 4fc4e758..45a8937e 100644 --- a/common/common.go +++ b/common/common.go @@ -2,15 +2,22 @@ package common import ( + "context" "errors" "fmt" "net" + "net/http" + "net/url" "os" + "os/exec" + "path/filepath" + "strings" "sync" "time" "github.com/pires/go-proxyproto" + "github.com/drakkan/sftpgo/httpclient" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/metrics" "github.com/drakkan/sftpgo/utils" @@ -75,6 +82,7 @@ var ( ErrGenericFailure = errors.New("failure") ErrQuotaExceeded = errors.New("denying write due to space limit") ErrSkipPermissionsCheck = errors.New("permission check skipped") + ErrConnectionDenied = errors.New("You are not allowed to connect") ) var ( @@ -221,7 +229,11 @@ type Configuration struct { // connection will be accepted and the header will be ignored. // If proxy protocol is set to 2 and we receive a proxy header from an IP that is not in the list then the // connection will be rejected. - ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + ProxyAllowed []string `json:"proxy_allowed" mapstructure:"proxy_allowed"` + // Absolute path to an external program or an HTTP URL to invoke after a user connects + // and before he tries to login. It allows you to reject the connection based on the source + // ip address. Leave empty do disable. + PostConnectHook string `json:"post_connect_hook" mapstructure:"post_connect_hook"` idleTimeoutAsDuration time.Duration idleLoginTimeout time.Duration } @@ -264,6 +276,56 @@ func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Lis return proxyListener, nil } +// ExecutePostConnectHook executes the post connect hook if defined +func (c *Configuration) ExecutePostConnectHook(remoteAddr net.Addr, protocol string) error { + if len(c.PostConnectHook) == 0 { + return nil + } + ip := utils.GetIPFromRemoteAddress(remoteAddr.String()) + if strings.HasPrefix(c.PostConnectHook, "http") { + var url *url.URL + url, err := url.Parse(c.PostConnectHook) + if err != nil { + logger.Warn(protocol, "", "Login from ip %#v denied, invalid post connect hook %#v: %v", + ip, c.PostConnectHook, err) + return err + } + httpClient := httpclient.GetHTTPClient() + q := url.Query() + q.Add("ip", ip) + q.Add("protocol", protocol) + url.RawQuery = q.Encode() + + resp, err := httpClient.Get(url.String()) + if err != nil { + logger.Warn(protocol, "", "Login from ip %#v denied, error executing post connect hook: %v", ip, err) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + logger.Warn(protocol, "", "Login from ip %#v denied, post connect hook response code: %v", ip, resp.StatusCode) + return errUnexpectedHTTResponse + } + return nil + } + if !filepath.IsAbs(c.PostConnectHook) { + err := fmt.Errorf("invalid post connect hook %#v", c.PostConnectHook) + logger.Warn(protocol, "", "Login from ip %#v denied: %v", ip, err) + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, c.PostConnectHook) + cmd.Env = append(os.Environ(), + fmt.Sprintf("SFTPGO_CONNECTION_IP=%v", ip), + fmt.Sprintf("SFTPGO_CONNECTION_PROTOCOL=%v", protocol)) + err := cmd.Run() + if err != nil { + logger.Warn(protocol, "", "Login from ip %#v denied, connect hook error: %v", ip, err) + } + return err +} + // ActiveConnections holds the currect active connections with the associated transfers type ActiveConnections struct { sync.RWMutex diff --git a/common/common_test.go b/common/common_test.go index 84c47652..c5de3726 100644 --- a/common/common_test.go +++ b/common/common_test.go @@ -5,6 +5,8 @@ import ( "net" "net/http" "os" + "os/exec" + "runtime" "strings" "testing" "time" @@ -367,3 +369,44 @@ func TestProxyProtocol(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } } + +func TestPostConnectHook(t *testing.T) { + Config.PostConnectHook = "" + + remoteAddr := &net.IPAddr{ + IP: net.ParseIP("127.0.0.1"), + Zone: "", + } + + assert.NoError(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolFTP)) + + Config.PostConnectHook = "http://foo\x7f.com/" + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolSFTP)) + + Config.PostConnectHook = "http://invalid:1234/" + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolSFTP)) + + Config.PostConnectHook = fmt.Sprintf("http://%v/404", httpAddr) + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolFTP)) + + Config.PostConnectHook = fmt.Sprintf("http://%v", httpAddr) + assert.NoError(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolFTP)) + + Config.PostConnectHook = "invalid" + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolFTP)) + + if runtime.GOOS == osWindows { + Config.PostConnectHook = "C:\\bad\\command" + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolSFTP)) + } else { + Config.PostConnectHook = "/invalid/path" + assert.Error(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolSFTP)) + + hookCmd, err := exec.LookPath("true") + assert.NoError(t, err) + Config.PostConnectHook = hookCmd + assert.NoError(t, Config.ExecutePostConnectHook(remoteAddr, ProtocolSFTP)) + } + + Config.PostConnectHook = "" +} diff --git a/docs/custom-actions.md b/docs/custom-actions.md index 8907d67d..5ca9cf6b 100644 --- a/docs/custom-actions.md +++ b/docs/custom-actions.md @@ -1,6 +1,6 @@ # 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 "common" 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. If an upload is aborted for quota limits SFTPGo tries to remove the partial file, so if the notification reports a zero size file and a quota exceeded error the file has been deleted. 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`. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 32ea4d65..5ddd7a2c 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -46,7 +46,7 @@ The configuration file contains the following sections: - **"common"**, configuration parameters shared among all the supported protocols - `idle_timeout`, integer. Time in minutes after which an idle client will be disconnected. 0 means disabled. Default: 15 - `upload_mode` integer. 0 means standard: the files are uploaded directly to the requested path. 1 means atomic: files are uploaded to a temporary path and renamed to the requested path when the client ends the upload. Atomic mode avoids problems such as a web server that serves partial files when the files are being uploaded. In atomic mode, if there is an upload error, the temporary file is deleted and so the requested upload path will not contain a partial file. 2 means atomic with resume support: same as atomic but if there is an upload error, the temporary file is renamed to the requested path and not deleted. This way, a client can reconnect and resume the upload. - - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details + - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See [Custom Actions](./custom-actions.md) for more details - `execute_on`, list of strings. Valid values are `download`, `upload`, `pre-delete`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions. - `hook`, string. Absolute path to the command to execute or HTTP URL to notify. - `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored. @@ -57,6 +57,7 @@ The configuration file contains the following sections: - `proxy_allowed`, List of IP addresses and IP ranges allowed to send the proxy header: - If `proxy_protocol` is set to 1 and we receive a proxy header from an IP that is not in the list then the connection will be accepted and the header will be ignored - If `proxy_protocol` is set to 2 and we receive a proxy header from an IP that is not in the list then the connection will be rejected + - `post_connect_hook`, string. Absolute path to the command to execute or HTTP URL to notify. See [Post connect hook](./post-connect-hook.md) for more details. Leave empty to disable - **"sftpd"**, the configuration for the SFTP server - `bind_port`, integer. The port used for serving SFTP requests. Default: 2022 - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "" @@ -75,7 +76,7 @@ The configuration file contains the following sections: - `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to disable login banner. - `setstat_mode`, integer. Deprecated, please use the same key in `common` section. - `enabled_ssh_commands`, list of enabled SSH commands. `*` enables all supported commands. More information can be found [here](./ssh-commands.md). - - `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See the "Keyboard Interactive Authentication" paragraph for more details. + - `keyboard_interactive_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for keyboard interactive authentication. See [Keyboard Interactive Authentication](./keyboard-interactive.md) for more details. - `proxy_protocol`, integer. Deprecated, please use the same key in `common` section. - `proxy_allowed`, list of strings. Deprecated, please use the same key in `common` section. - **"ftpd"**, the configuration for the FTP server @@ -105,15 +106,15 @@ The configuration file contains the following sections: - 2, quota is updated each time a user uploads or deletes a file, but only for users with quota restrictions and for virtual folders. With this configuration, the `quota scan` and `folder_quota_scan` REST API can still be used to periodically update space usage for users without quota restrictions and for folders - `pool_size`, integer. Sets the maximum number of open connections for `mysql` and `postgresql` driver. Default 0 (unlimited) - `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username - - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details + - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See [Custom Actions](./custom-actions.md) for more details - `execute_on`, list of strings. Valid values are `add`, `update`, `delete`. `update` action will not be fired for internal updates such as the last login or the user quota fields. - `hook`, string. Absolute path to the command to execute or HTTP URL to notify. - `external_auth_program`, string. Deprecated, please use `external_auth_hook`. - - `external_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for users authentication. See the "External Authentication" paragraph for more details. Leave empty to disable. + - `external_auth_hook`, string. Absolute path to an external program or an HTTP URL to invoke for users authentication. See [External Authentication](./external-auth.md) for more details. Leave empty to disable. - `external_auth_scope`, integer. 0 means all supported authetication scopes (passwords, public keys and keyboard interactive). 1 means passwords only. 2 means public keys only. 4 means key keyboard interactive only. The flags can be combined, for example 6 means public keys and keyboard interactive - `credentials_path`, string. It defines the directory for storing user provided credential files such as Google Cloud Storage credentials. This can be an absolute path or a path relative to the config dir - `pre_login_program`, string. Deprecated, please use `pre_login_hook`. - - `pre_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to modify user details just before the login. See the "Dynamic user modification" paragraph for more details. Leave empty to disable. + - `pre_login_hook`, string. Absolute path to an external program or an HTTP URL to invoke to modify user details just before the login. See [Dynamic user modification](./dynamic-user-mod.md) for more details. Leave empty to disable. - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 8080 - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1" diff --git a/docs/post-connect-hook.md b/docs/post-connect-hook.md new file mode 100644 index 00000000..ffca752b --- /dev/null +++ b/docs/post-connect-hook.md @@ -0,0 +1,25 @@ +# Post connect hook + +This hook is executed as soon as a new connection is estabilished. It notifies the connection's IP address and protocol. Based on the received response, the connection is accepted or rejected. This way you can implement your own blacklist/whitelist of IP addresses. +Please keep in mind that you can easily configure specialized program such as [Fail2ban](http://www.fail2ban.org/) for brute force protection. + +The `post_connect_hook` can be defined as the absolute path of your program or an HTTP URL. + +If the hook defines an external program it can reads the following environment variables: + +- `SFTPGO_CONNECTION_IP` +- `SFTPGO_CONNECTION_PROTOCOL` + +If the external command completes with a zero exit status the connection will be accepted otherwise rejected. + +Previous global environment variables aren't cleared when the script is called. +The program must finish within 20 seconds. + +If the hook defines an HTTP URL then this URL will be invoked as HTTP GET with the following query parameters: + +- `ip` +- `protocol` + +The connection is accepted if the HTTP response code is `200` otherwise rejeted. + +The HTTP request will use the global configuration for HTTP clients. diff --git a/ftpd/ftpd_test.go b/ftpd/ftpd_test.go index d3ef5e4d..62d4f6aa 100644 --- a/ftpd/ftpd_test.go +++ b/ftpd/ftpd_test.go @@ -64,12 +64,13 @@ CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI= ) var ( - allPerms = []string{dataprovider.PermAny} - homeBasePath string - hookCmdPath string - extAuthPath string - preLoginPath string - logFilePath string + allPerms = []string{dataprovider.PermAny} + homeBasePath string + hookCmdPath string + extAuthPath string + preLoginPath string + postConnectPath string + logFilePath string ) func TestMain(m *testing.M) { @@ -77,7 +78,6 @@ func TestMain(m *testing.M) { bannerFileName := "banner_file" bannerFile := filepath.Join(configDir, bannerFileName) logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel) - logger.DebugToConsole("aaa %v", bannerFile) err := ioutil.WriteFile(bannerFile, []byte("SFTPGo test ready\nsimple banner line\n"), os.ModePerm) if err != nil { logger.ErrorToConsole("error creating banner file: %v", err) @@ -144,6 +144,7 @@ func TestMain(m *testing.M) { extAuthPath = filepath.Join(homeBasePath, "extauth.sh") preLoginPath = filepath.Join(homeBasePath, "prelogin.sh") + postConnectPath = filepath.Join(homeBasePath, "postconnect.sh") go func() { logger.Debug(logSender, "", "initializing FTP server with config %+v", ftpdConf) @@ -169,6 +170,7 @@ func TestMain(m *testing.M) { os.Remove(bannerFile) os.Remove(extAuthPath) os.Remove(preLoginPath) + os.Remove(postConnectPath) os.Remove(certPath) os.Remove(keyPath) os.Exit(exitCode) @@ -280,7 +282,7 @@ func TestLoginExternalAuth(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 0 @@ -330,7 +332,7 @@ func TestPreLoginHook(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) assert.NoError(t, err) providerConf.PreLoginHook = preLoginPath err = dataprovider.Initialize(providerConf, configDir) @@ -360,7 +362,7 @@ func TestPreLoginHook(t *testing.T) { assert.NoError(t, err) } - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm) assert.NoError(t, err) client, err = getFTPClient(u, false) if !assert.Error(t, err) { @@ -368,7 +370,7 @@ func TestPreLoginHook(t *testing.T) { assert.NoError(t, err) } user.Status = 0 - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm) assert.NoError(t, err) client, err = getFTPClient(u, false) if !assert.Error(t, err, "pre-login script returned a disabled user, login must fail") { @@ -391,6 +393,58 @@ func TestPreLoginHook(t *testing.T) { assert.NoError(t, err) } +func TestPostConnectHook(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + common.Config.PostConnectHook = postConnectPath + + u := getTestUser() + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + err = ioutil.WriteFile(postConnectPath, getPostConnectScriptContent(0), os.ModePerm) + assert.NoError(t, err) + client, err := getFTPClient(user, true) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err := client.Quit() + assert.NoError(t, err) + } + err = ioutil.WriteFile(postConnectPath, getPostConnectScriptContent(1), os.ModePerm) + assert.NoError(t, err) + client, err = getFTPClient(user, true) + if !assert.Error(t, err) { + err := client.Quit() + assert.NoError(t, err) + } + + common.Config.PostConnectHook = "http://127.0.0.1:8079/api/v1/version" + + client, err = getFTPClient(user, false) + if assert.NoError(t, err) { + err = checkBasicFTP(client) + assert.NoError(t, err) + err := client.Quit() + assert.NoError(t, err) + } + + common.Config.PostConnectHook = "http://127.0.0.1:8079/notfound" + + client, err = getFTPClient(user, true) + if !assert.Error(t, err) { + err := client.Quit() + assert.NoError(t, err) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + common.Config.PostConnectHook = "" +} + func TestMaxSessions(t *testing.T) { u := getTestUser() u.MaxSessions = 1 @@ -1246,6 +1300,12 @@ func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []by return content } +func getPostConnectScriptContent(exitCode int) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte(fmt.Sprintf("exit %v", exitCode))...) + return content +} + func createTestFile(path string, size int64) error { baseDir := filepath.Dir(path) if _, err := os.Stat(baseDir); os.IsNotExist(err) { diff --git a/ftpd/server.go b/ftpd/server.go index ed99debc..ac621ff3 100644 --- a/ftpd/server.go +++ b/ftpd/server.go @@ -92,6 +92,9 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) { // ClientConnected is called to send the very first welcome message func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) { + if err := common.Config.ExecutePostConnectHook(cc.RemoteAddr(), common.ProtocolFTP); err != nil { + return common.ErrConnectionDenied.Error(), err + } connID := fmt.Sprintf("%v", cc.ID()) user := dataprovider.User{} connection := &Connection{ diff --git a/sftpd/server.go b/sftpd/server.go index c5f9684d..ab1f6b8d 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -264,6 +264,10 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server // we'll set a Deadline for handshake to complete, the default is 2 minutes as OpenSSH conn.SetDeadline(time.Now().Add(handshakeTimeout)) //nolint:errcheck remoteAddr := conn.RemoteAddr() + if err := common.Config.ExecutePostConnectHook(remoteAddr, common.ProtocolSSH); err != nil { + conn.Close() + return + } sconn, chans, reqs, err := ssh.NewServerConn(conn, config) if err != nil { logger.Warn(logSender, "", "failed to accept an incoming connection: %v", err) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 5d6c14ca..4d2c7faa 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -126,6 +126,7 @@ var ( extAuthPath string keyIntAuthPath string preLoginPath string + postConnectPath string logFilePath string ) @@ -186,7 +187,7 @@ func TestMain(m *testing.M) { sftpdConf.EnabledSSHCommands = []string{"*"} keyIntAuthPath = filepath.Join(homeBasePath, "keyintauth.sh") - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), os.ModePerm) if err != nil { logger.ErrorToConsole("error writing keyboard interactive script: %v", err) os.Exit(1) @@ -199,6 +200,7 @@ func TestMain(m *testing.M) { gitWrapPath = filepath.Join(homeBasePath, "gitwrap.sh") extAuthPath = filepath.Join(homeBasePath, "extauth.sh") preLoginPath = filepath.Join(homeBasePath, "prelogin.sh") + postConnectPath = filepath.Join(homeBasePath, "postconnect.sh") err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600) if err != nil { logger.WarnToConsole("unable to save public key to file: %v", err) @@ -208,7 +210,7 @@ func TestMain(m *testing.M) { logger.WarnToConsole("unable to save private key to file: %v", err) } err = ioutil.WriteFile(gitWrapPath, []byte(fmt.Sprintf("%v -i %v -oStrictHostKeyChecking=no %v\n", - sshPath, privateKeyPath, scriptArgs)), 0755) + sshPath, privateKeyPath, scriptArgs)), os.ModePerm) if err != nil { logger.WarnToConsole("unable to save gitwrap shell script: %v", err) } @@ -271,6 +273,7 @@ func TestMain(m *testing.M) { os.Remove(gitWrapPath) os.Remove(extAuthPath) os.Remove(preLoginPath) + os.Remove(postConnectPath) os.Remove(keyIntAuthPath) os.Exit(exitCode) } @@ -994,7 +997,7 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) { }...) user, _, err := httpd.AddUser(u, http.StatusOK) assert.NoError(t, err) - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), os.ModePerm) assert.NoError(t, err) client, err := getSftpClient(user, true) if !assert.Error(t, err, "login with public key is disallowed and must fail") { @@ -1283,7 +1286,7 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) { } user, _, err := httpd.AddUser(getTestUser(false), http.StatusOK) assert.NoError(t, err) - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), os.ModePerm) assert.NoError(t, err) client, err := getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) if assert.NoError(t, err) { @@ -1300,19 +1303,19 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) { user.Status = 1 user, _, err = httpd.UpdateUser(user, http.StatusOK) assert.NoError(t, err) - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, -1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, -1), os.ModePerm) assert.NoError(t, err) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) if !assert.Error(t, err, "keyboard interactive auth must fail the script returned -1") { client.Close() } - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, true, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, true, 1), os.ModePerm) assert.NoError(t, err) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) if !assert.Error(t, err, "keyboard interactive auth must fail the script returned bad json") { client.Close() } - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 5, true, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 5, true, 1), os.ModePerm) assert.NoError(t, err) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) if !assert.Error(t, err, "keyboard interactive auth must fail the script returned bad json") { @@ -1335,7 +1338,7 @@ func TestPreLoginScript(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) assert.NoError(t, err) providerConf.PreLoginHook = preLoginPath err = dataprovider.Initialize(providerConf, configDir) @@ -1348,14 +1351,14 @@ func TestPreLoginScript(t *testing.T) { defer client.Close() assert.NoError(t, checkBasicSFTP(client)) } - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, true), os.ModePerm) assert.NoError(t, err) client, err = getSftpClient(u, usePubKey) if !assert.Error(t, err, "pre-login script returned a non json response, login must fail") { client.Close() } user.Status = 0 - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm) assert.NoError(t, err) client, err = getSftpClient(u, usePubKey) if !assert.Error(t, err, "pre-login script returned a disabled user, login must fail") { @@ -1387,7 +1390,7 @@ func TestPreLoginUserCreation(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) + err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm) assert.NoError(t, err) providerConf.PreLoginHook = preLoginPath err = dataprovider.Initialize(providerConf, configDir) @@ -1420,6 +1423,54 @@ func TestPreLoginUserCreation(t *testing.T) { assert.NoError(t, err) } +func TestPostConnectHook(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } + common.Config.PostConnectHook = postConnectPath + + usePubKey := true + u := getTestUser(usePubKey) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + err = ioutil.WriteFile(postConnectPath, getPostConnectScriptContent(0), os.ModePerm) + assert.NoError(t, err) + client, err := getSftpClient(u, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = checkBasicSFTP(client) + assert.NoError(t, err) + } + err = ioutil.WriteFile(postConnectPath, getPostConnectScriptContent(1), os.ModePerm) + assert.NoError(t, err) + client, err = getSftpClient(u, usePubKey) + if !assert.Error(t, err) { + client.Close() + } + + common.Config.PostConnectHook = "http://127.0.0.1:8080/api/v1/version" + + client, err = getSftpClient(u, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = checkBasicSFTP(client) + assert.NoError(t, err) + } + + common.Config.PostConnectHook = "http://127.0.0.1:8080/notfound" + client, err = getSftpClient(u, usePubKey) + if !assert.Error(t, err) { + client.Close() + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + + common.Config.PostConnectHook = "" +} + func TestLoginExternalAuthPwdAndPubKey(t *testing.T) { if runtime.GOOS == osWindows { t.Skip("this test is not available on Windows") @@ -1432,7 +1483,7 @@ func TestLoginExternalAuthPwdAndPubKey(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 0 @@ -1460,7 +1511,7 @@ func TestLoginExternalAuthPwdAndPubKey(t *testing.T) { usePubKey = false u = getTestUser(usePubKey) u.PublicKeys = []string{} - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) assert.NoError(t, err) client, err = getSftpClient(u, usePubKey) if assert.NoError(t, err) { @@ -1505,7 +1556,7 @@ func TestExternalAuthDifferentUsername(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, extAuthUsername), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, extAuthUsername), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 0 @@ -1591,7 +1642,7 @@ func TestLoginExternalAuth(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = authScope @@ -1655,14 +1706,14 @@ func TestLoginExternalAuthInteractive(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 4 err = dataprovider.Initialize(providerConf, configDir) assert.NoError(t, err) - err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), 0755) + err = ioutil.WriteFile(keyIntAuthPath, getKeyboardInteractiveScriptContent([]string{"1", "2"}, 0, false, 1), os.ModePerm) assert.NoError(t, err) client, err := getKeyboardInteractiveSftpClient(u, []string{"1", "2"}) if assert.NoError(t, err) { @@ -1711,7 +1762,7 @@ func TestLoginExternalAuthErrors(t *testing.T) { err = config.LoadConfig(configDir, "") assert.NoError(t, err) providerConf := config.GetProviderConf() - err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, true, ""), 0755) + err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, true, ""), os.ModePerm) assert.NoError(t, err) providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthScope = 0 @@ -4138,7 +4189,7 @@ func TestOpenError(t *testing.T) { assert.NoError(t, err) _, err = client.ReadDir(".") assert.Error(t, err, "read dir must fail if we have no filesystem read permissions") - err = os.Chmod(user.GetHomeDir(), 0755) + err = os.Chmod(user.GetHomeDir(), os.ModePerm) assert.NoError(t, err) testFileSize := int64(65535) testFileName := "test_file.dat" @@ -4162,7 +4213,7 @@ func TestOpenError(t *testing.T) { assert.NoError(t, err) _, err = client.Lstat(testFileName) assert.Error(t, err, "file stat must fail if we have no filesystem read permissions") - err = os.Chmod(user.GetHomeDir(), 0755) + err = os.Chmod(user.GetHomeDir(), os.ModePerm) assert.NoError(t, err) err = os.Chmod(filepath.Join(user.GetHomeDir(), "test"), 0000) assert.NoError(t, err) @@ -4170,7 +4221,7 @@ func TestOpenError(t *testing.T) { if assert.Error(t, err) { assert.Contains(t, err.Error(), sftp.ErrSSHFxPermissionDenied.Error()) } - err = os.Chmod(filepath.Join(user.GetHomeDir(), "test"), 0755) + err = os.Chmod(filepath.Join(user.GetHomeDir(), "test"), os.ModePerm) assert.NoError(t, err) err = os.Remove(localDownloadPath) assert.NoError(t, err) @@ -6546,7 +6597,7 @@ func TestSCPPermsSubDirs(t *testing.T) { assert.NoError(t, err) err = scpDownload(localPath, remoteDownPath, false, false) assert.Error(t, err, "download a file with no system permissions must fail") - err = os.Chmod(subPath, 0755) + err = os.Chmod(subPath, os.ModePerm) assert.NoError(t, err) } err = os.Remove(localPath) @@ -7504,6 +7555,12 @@ func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []by return content } +func getPostConnectScriptContent(exitCode int) []byte { + content := []byte("#!/bin/sh\n\n") + content = append(content, []byte(fmt.Sprintf("exit %v", exitCode))...) + return content +} + func printLatestLogs(maxNumberOfLines int) { var lines []string f, err := os.Open(logFilePath) diff --git a/sftpgo.json b/sftpgo.json index cee5b626..512ec0e5 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -8,7 +8,8 @@ }, "setstat_mode": 0, "proxy_protocol": 0, - "proxy_allowed": [] + "proxy_allowed": [], + "post_connect_hook": "" }, "sftpd": { "bind_port": 2022,