add post connect hook

Fixes #144
This commit is contained in:
Nicola Murino
2020-07-30 22:33:49 +02:00
parent 59a21158a6
commit 22338ed478
10 changed files with 297 additions and 41 deletions

View File

@@ -2,15 +2,22 @@
package common package common
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http"
"net/url"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
"github.com/pires/go-proxyproto" "github.com/pires/go-proxyproto"
"github.com/drakkan/sftpgo/httpclient"
"github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/metrics" "github.com/drakkan/sftpgo/metrics"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
@@ -75,6 +82,7 @@ var (
ErrGenericFailure = errors.New("failure") ErrGenericFailure = errors.New("failure")
ErrQuotaExceeded = errors.New("denying write due to space limit") ErrQuotaExceeded = errors.New("denying write due to space limit")
ErrSkipPermissionsCheck = errors.New("permission check skipped") ErrSkipPermissionsCheck = errors.New("permission check skipped")
ErrConnectionDenied = errors.New("You are not allowed to connect")
) )
var ( var (
@@ -221,7 +229,11 @@ type Configuration struct {
// connection will be accepted and the header will be ignored. // 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 // 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. // 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 idleTimeoutAsDuration time.Duration
idleLoginTimeout time.Duration idleLoginTimeout time.Duration
} }
@@ -264,6 +276,56 @@ func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Lis
return proxyListener, nil 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 // ActiveConnections holds the currect active connections with the associated transfers
type ActiveConnections struct { type ActiveConnections struct {
sync.RWMutex sync.RWMutex

View File

@@ -5,6 +5,8 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"os/exec"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -367,3 +369,44 @@ func TestProxyProtocol(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode) 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 = ""
}

View File

@@ -1,6 +1,6 @@
# Custom Actions # Custom Actions
The `actions` struct inside the "sftpd" configuration section allows to configure the actions for file operations and SSH commands. The `actions` struct inside the "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 `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`. 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`.

View File

@@ -46,7 +46,7 @@ The configuration file contains the following sections:
- **"common"**, configuration parameters shared among all the supported protocols - **"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 - `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. - `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. - `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. - `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. - `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: - `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 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 - 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 - **"sftpd"**, the configuration for the SFTP server
- `bind_port`, integer. The port used for serving SFTP requests. Default: 2022 - `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: "" - `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. - `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. - `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). - `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_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. - `proxy_allowed`, list of strings. Deprecated, please use the same key in `common` section.
- **"ftpd"**, the configuration for the FTP server - **"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 - 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) - `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 - `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. - `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. - `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_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 - `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 - `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_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 - **"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_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" - `bind_address`, string. Leave blank to listen on all available network interfaces. Default: "127.0.0.1"

25
docs/post-connect-hook.md Normal file
View File

@@ -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.

View File

@@ -64,12 +64,13 @@ CzgWkxiz7XE4lgUwX44FCXZM3+JeUbI=
) )
var ( var (
allPerms = []string{dataprovider.PermAny} allPerms = []string{dataprovider.PermAny}
homeBasePath string homeBasePath string
hookCmdPath string hookCmdPath string
extAuthPath string extAuthPath string
preLoginPath string preLoginPath string
logFilePath string postConnectPath string
logFilePath string
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -77,7 +78,6 @@ func TestMain(m *testing.M) {
bannerFileName := "banner_file" bannerFileName := "banner_file"
bannerFile := filepath.Join(configDir, bannerFileName) bannerFile := filepath.Join(configDir, bannerFileName)
logger.InitLogger(logFilePath, 5, 1, 28, false, zerolog.DebugLevel) 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) err := ioutil.WriteFile(bannerFile, []byte("SFTPGo test ready\nsimple banner line\n"), os.ModePerm)
if err != nil { if err != nil {
logger.ErrorToConsole("error creating banner file: %v", err) logger.ErrorToConsole("error creating banner file: %v", err)
@@ -144,6 +144,7 @@ func TestMain(m *testing.M) {
extAuthPath = filepath.Join(homeBasePath, "extauth.sh") extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
preLoginPath = filepath.Join(homeBasePath, "prelogin.sh") preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
go func() { go func() {
logger.Debug(logSender, "", "initializing FTP server with config %+v", ftpdConf) logger.Debug(logSender, "", "initializing FTP server with config %+v", ftpdConf)
@@ -169,6 +170,7 @@ func TestMain(m *testing.M) {
os.Remove(bannerFile) os.Remove(bannerFile)
os.Remove(extAuthPath) os.Remove(extAuthPath)
os.Remove(preLoginPath) os.Remove(preLoginPath)
os.Remove(postConnectPath)
os.Remove(certPath) os.Remove(certPath)
os.Remove(keyPath) os.Remove(keyPath)
os.Exit(exitCode) os.Exit(exitCode)
@@ -280,7 +282,7 @@ func TestLoginExternalAuth(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = 0 providerConf.ExternalAuthScope = 0
@@ -330,7 +332,7 @@ func TestPreLoginHook(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.PreLoginHook = preLoginPath providerConf.PreLoginHook = preLoginPath
err = dataprovider.Initialize(providerConf, configDir) err = dataprovider.Initialize(providerConf, configDir)
@@ -360,7 +362,7 @@ func TestPreLoginHook(t *testing.T) {
assert.NoError(t, err) 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) assert.NoError(t, err)
client, err = getFTPClient(u, false) client, err = getFTPClient(u, false)
if !assert.Error(t, err) { if !assert.Error(t, err) {
@@ -368,7 +370,7 @@ func TestPreLoginHook(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
user.Status = 0 user.Status = 0
err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), 0755) err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
client, err = getFTPClient(u, false) client, err = getFTPClient(u, false)
if !assert.Error(t, err, "pre-login script returned a disabled user, login must fail") { 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) 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) { func TestMaxSessions(t *testing.T) {
u := getTestUser() u := getTestUser()
u.MaxSessions = 1 u.MaxSessions = 1
@@ -1246,6 +1300,12 @@ func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []by
return content 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 { func createTestFile(path string, size int64) error {
baseDir := filepath.Dir(path) baseDir := filepath.Dir(path)
if _, err := os.Stat(baseDir); os.IsNotExist(err) { if _, err := os.Stat(baseDir); os.IsNotExist(err) {

View File

@@ -92,6 +92,9 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
// ClientConnected is called to send the very first welcome message // ClientConnected is called to send the very first welcome message
func (s *Server) ClientConnected(cc ftpserver.ClientContext) (string, error) { 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()) connID := fmt.Sprintf("%v", cc.ID())
user := dataprovider.User{} user := dataprovider.User{}
connection := &Connection{ connection := &Connection{

View File

@@ -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 // we'll set a Deadline for handshake to complete, the default is 2 minutes as OpenSSH
conn.SetDeadline(time.Now().Add(handshakeTimeout)) //nolint:errcheck conn.SetDeadline(time.Now().Add(handshakeTimeout)) //nolint:errcheck
remoteAddr := conn.RemoteAddr() remoteAddr := conn.RemoteAddr()
if err := common.Config.ExecutePostConnectHook(remoteAddr, common.ProtocolSSH); err != nil {
conn.Close()
return
}
sconn, chans, reqs, err := ssh.NewServerConn(conn, config) sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil { if err != nil {
logger.Warn(logSender, "", "failed to accept an incoming connection: %v", err) logger.Warn(logSender, "", "failed to accept an incoming connection: %v", err)

View File

@@ -126,6 +126,7 @@ var (
extAuthPath string extAuthPath string
keyIntAuthPath string keyIntAuthPath string
preLoginPath string preLoginPath string
postConnectPath string
logFilePath string logFilePath string
) )
@@ -186,7 +187,7 @@ func TestMain(m *testing.M) {
sftpdConf.EnabledSSHCommands = []string{"*"} sftpdConf.EnabledSSHCommands = []string{"*"}
keyIntAuthPath = filepath.Join(homeBasePath, "keyintauth.sh") 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 { if err != nil {
logger.ErrorToConsole("error writing keyboard interactive script: %v", err) logger.ErrorToConsole("error writing keyboard interactive script: %v", err)
os.Exit(1) os.Exit(1)
@@ -199,6 +200,7 @@ func TestMain(m *testing.M) {
gitWrapPath = filepath.Join(homeBasePath, "gitwrap.sh") gitWrapPath = filepath.Join(homeBasePath, "gitwrap.sh")
extAuthPath = filepath.Join(homeBasePath, "extauth.sh") extAuthPath = filepath.Join(homeBasePath, "extauth.sh")
preLoginPath = filepath.Join(homeBasePath, "prelogin.sh") preLoginPath = filepath.Join(homeBasePath, "prelogin.sh")
postConnectPath = filepath.Join(homeBasePath, "postconnect.sh")
err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600) err = ioutil.WriteFile(pubKeyPath, []byte(testPubKey+"\n"), 0600)
if err != nil { if err != nil {
logger.WarnToConsole("unable to save public key to file: %v", err) 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) 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", 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 { if err != nil {
logger.WarnToConsole("unable to save gitwrap shell script: %v", err) logger.WarnToConsole("unable to save gitwrap shell script: %v", err)
} }
@@ -271,6 +273,7 @@ func TestMain(m *testing.M) {
os.Remove(gitWrapPath) os.Remove(gitWrapPath)
os.Remove(extAuthPath) os.Remove(extAuthPath)
os.Remove(preLoginPath) os.Remove(preLoginPath)
os.Remove(postConnectPath)
os.Remove(keyIntAuthPath) os.Remove(keyIntAuthPath)
os.Exit(exitCode) os.Exit(exitCode)
} }
@@ -994,7 +997,7 @@ func TestMultiStepLoginKeyAndKeyInt(t *testing.T) {
}...) }...)
user, _, err := httpd.AddUser(u, http.StatusOK) user, _, err := httpd.AddUser(u, http.StatusOK)
assert.NoError(t, err) 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) assert.NoError(t, err)
client, err := getSftpClient(user, true) client, err := getSftpClient(user, true)
if !assert.Error(t, err, "login with public key is disallowed and must fail") { 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) user, _, err := httpd.AddUser(getTestUser(false), http.StatusOK)
assert.NoError(t, err) 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) assert.NoError(t, err)
client, err := getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) client, err := getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
if assert.NoError(t, err) { if assert.NoError(t, err) {
@@ -1300,19 +1303,19 @@ func TestLoginKeyboardInteractiveAuth(t *testing.T) {
user.Status = 1 user.Status = 1
user, _, err = httpd.UpdateUser(user, http.StatusOK) user, _, err = httpd.UpdateUser(user, http.StatusOK)
assert.NoError(t, err) 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) assert.NoError(t, err)
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
if !assert.Error(t, err, "keyboard interactive auth must fail the script returned -1") { if !assert.Error(t, err, "keyboard interactive auth must fail the script returned -1") {
client.Close() 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) assert.NoError(t, err)
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
if !assert.Error(t, err, "keyboard interactive auth must fail the script returned bad json") { if !assert.Error(t, err, "keyboard interactive auth must fail the script returned bad json") {
client.Close() 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) assert.NoError(t, err)
client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"}) client, err = getKeyboardInteractiveSftpClient(user, []string{"1", "2"})
if !assert.Error(t, err, "keyboard interactive auth must fail the script returned bad json") { 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, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.PreLoginHook = preLoginPath providerConf.PreLoginHook = preLoginPath
err = dataprovider.Initialize(providerConf, configDir) err = dataprovider.Initialize(providerConf, configDir)
@@ -1348,14 +1351,14 @@ func TestPreLoginScript(t *testing.T) {
defer client.Close() defer client.Close()
assert.NoError(t, checkBasicSFTP(client)) 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) assert.NoError(t, err)
client, err = getSftpClient(u, usePubKey) client, err = getSftpClient(u, usePubKey)
if !assert.Error(t, err, "pre-login script returned a non json response, login must fail") { if !assert.Error(t, err, "pre-login script returned a non json response, login must fail") {
client.Close() client.Close()
} }
user.Status = 0 user.Status = 0
err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), 0755) err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(user, false), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
client, err = getSftpClient(u, usePubKey) client, err = getSftpClient(u, usePubKey)
if !assert.Error(t, err, "pre-login script returned a disabled user, login must fail") { 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, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), 0755) err = ioutil.WriteFile(preLoginPath, getPreLoginScriptContent(u, false), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.PreLoginHook = preLoginPath providerConf.PreLoginHook = preLoginPath
err = dataprovider.Initialize(providerConf, configDir) err = dataprovider.Initialize(providerConf, configDir)
@@ -1420,6 +1423,54 @@ func TestPreLoginUserCreation(t *testing.T) {
assert.NoError(t, err) 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) { func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
if runtime.GOOS == osWindows { if runtime.GOOS == osWindows {
t.Skip("this test is not available on Windows") t.Skip("this test is not available on Windows")
@@ -1432,7 +1483,7 @@ func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = 0 providerConf.ExternalAuthScope = 0
@@ -1460,7 +1511,7 @@ func TestLoginExternalAuthPwdAndPubKey(t *testing.T) {
usePubKey = false usePubKey = false
u = getTestUser(usePubKey) u = getTestUser(usePubKey)
u.PublicKeys = []string{} u.PublicKeys = []string{}
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
client, err = getSftpClient(u, usePubKey) client, err = getSftpClient(u, usePubKey)
if assert.NoError(t, err) { if assert.NoError(t, err) {
@@ -1505,7 +1556,7 @@ func TestExternalAuthDifferentUsername(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() 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) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = 0 providerConf.ExternalAuthScope = 0
@@ -1591,7 +1642,7 @@ func TestLoginExternalAuth(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = authScope providerConf.ExternalAuthScope = authScope
@@ -1655,14 +1706,14 @@ func TestLoginExternalAuthInteractive(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, false, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = 4 providerConf.ExternalAuthScope = 4
err = dataprovider.Initialize(providerConf, configDir) err = dataprovider.Initialize(providerConf, configDir)
assert.NoError(t, err) 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) assert.NoError(t, err)
client, err := getKeyboardInteractiveSftpClient(u, []string{"1", "2"}) client, err := getKeyboardInteractiveSftpClient(u, []string{"1", "2"})
if assert.NoError(t, err) { if assert.NoError(t, err) {
@@ -1711,7 +1762,7 @@ func TestLoginExternalAuthErrors(t *testing.T) {
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, true, ""), 0755) err = ioutil.WriteFile(extAuthPath, getExtAuthScriptContent(u, true, ""), os.ModePerm)
assert.NoError(t, err) assert.NoError(t, err)
providerConf.ExternalAuthHook = extAuthPath providerConf.ExternalAuthHook = extAuthPath
providerConf.ExternalAuthScope = 0 providerConf.ExternalAuthScope = 0
@@ -4138,7 +4189,7 @@ func TestOpenError(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
_, err = client.ReadDir(".") _, err = client.ReadDir(".")
assert.Error(t, err, "read dir must fail if we have no filesystem read permissions") 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) assert.NoError(t, err)
testFileSize := int64(65535) testFileSize := int64(65535)
testFileName := "test_file.dat" testFileName := "test_file.dat"
@@ -4162,7 +4213,7 @@ func TestOpenError(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
_, err = client.Lstat(testFileName) _, err = client.Lstat(testFileName)
assert.Error(t, err, "file stat must fail if we have no filesystem read permissions") 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) assert.NoError(t, err)
err = os.Chmod(filepath.Join(user.GetHomeDir(), "test"), 0000) err = os.Chmod(filepath.Join(user.GetHomeDir(), "test"), 0000)
assert.NoError(t, err) assert.NoError(t, err)
@@ -4170,7 +4221,7 @@ func TestOpenError(t *testing.T) {
if assert.Error(t, err) { if assert.Error(t, err) {
assert.Contains(t, err.Error(), sftp.ErrSSHFxPermissionDenied.Error()) 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) assert.NoError(t, err)
err = os.Remove(localDownloadPath) err = os.Remove(localDownloadPath)
assert.NoError(t, err) assert.NoError(t, err)
@@ -6546,7 +6597,7 @@ func TestSCPPermsSubDirs(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = scpDownload(localPath, remoteDownPath, false, false) err = scpDownload(localPath, remoteDownPath, false, false)
assert.Error(t, err, "download a file with no system permissions must fail") 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) assert.NoError(t, err)
} }
err = os.Remove(localPath) err = os.Remove(localPath)
@@ -7504,6 +7555,12 @@ func getPreLoginScriptContent(user dataprovider.User, nonJSONResponse bool) []by
return content 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) { func printLatestLogs(maxNumberOfLines int) {
var lines []string var lines []string
f, err := os.Open(logFilePath) f, err := os.Open(logFilePath)

View File

@@ -8,7 +8,8 @@
}, },
"setstat_mode": 0, "setstat_mode": 0,
"proxy_protocol": 0, "proxy_protocol": 0,
"proxy_allowed": [] "proxy_allowed": [],
"post_connect_hook": ""
}, },
"sftpd": { "sftpd": {
"bind_port": 2022, "bind_port": 2022,