Skip to content

Commit fa45c9c

Browse files
committed
allow to execute actions for file operations and SSH commands synchronously
The actions to run synchronously can be configured via the `execute_sync` configuration key. Executing an action synchronously means that SFTPGo will not return a result code to the client until your hook have completed its execution. Fixes #409
1 parent b67cd0d commit fa45c9c

File tree

11 files changed

+102
-46
lines changed

11 files changed

+102
-46
lines changed

common/actions.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ var (
3131
type ProtocolActions struct {
3232
// Valid values are download, upload, pre-delete, delete, rename, ssh_cmd. Empty slice to disable
3333
ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
34+
// Actions to be performed synchronously.
35+
// The pre-delete action is always executed synchronously while the other ones are asynchronous.
36+
// Executing an action synchronously means that SFTPGo will not return a result code to the client
37+
// (which is waiting for it) until your hook have completed its execution.
38+
ExecuteSync []string `json:"execute_sync" mapstructure:"execute_sync"`
3439
// Absolute path to an external program or an HTTP URL
3540
Hook string `json:"hook" mapstructure:"hook"`
3641
}
@@ -44,11 +49,16 @@ func InitializeActionHandler(handler ActionHandler) {
4449
actionHandler = handler
4550
}
4651

47-
// SSHCommandActionNotification executes the defined action for the specified SSH command.
48-
func SSHCommandActionNotification(user *dataprovider.User, filePath, target, sshCmd string, err error) {
49-
notification := newActionNotification(user, operationSSHCmd, filePath, target, sshCmd, ProtocolSSH, 0, err)
52+
// ExecuteActionNotification executes the defined hook, if any, for the specified action
53+
func ExecuteActionNotification(user *dataprovider.User, operation, filePath, target, sshCmd, protocol string, fileSize int64, err error) {
54+
notification := newActionNotification(user, operation, filePath, target, sshCmd, protocol, fileSize, err)
5055

51-
go actionHandler.Handle(notification) // nolint:errcheck
56+
if utils.IsStringInSlice(operation, Config.Actions.ExecuteSync) {
57+
actionHandler.Handle(notification) //nolint:errcheck
58+
return
59+
}
60+
61+
go actionHandler.Handle(notification) //nolint:errcheck
5262
}
5363

5464
// ActionHandler handles a notification for a Protocol Action.

common/actions_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func TestActionCMD(t *testing.T) {
110110
err = actionHandler.Handle(a)
111111
assert.NoError(t, err)
112112

113-
SSHCommandActionNotification(user, "path", "target", "sha1sum", nil)
113+
ExecuteActionNotification(user, operationSSHCmd, "path", "target", "sha1sum", ProtocolSSH, 0, nil)
114114

115115
Config.Actions = actionsCopy
116116
}

common/connection.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,7 @@ func (c *BaseConnection) RemoveFile(fs vfs.Fs, fsPath, virtualPath string, info
285285
}
286286
}
287287
if actionErr != nil {
288-
action := newActionNotification(&c.User, operationDelete, fsPath, "", "", c.protocol, size, nil)
289-
go actionHandler.Handle(action) // nolint:errcheck
288+
ExecuteActionNotification(&c.User, operationDelete, fsPath, "", "", c.protocol, size, nil)
290289
}
291290
return nil
292291
}
@@ -405,9 +404,7 @@ func (c *BaseConnection) Rename(virtualSourcePath, virtualTargetPath string) err
405404
c.updateQuotaAfterRename(fsDst, virtualSourcePath, virtualTargetPath, fsTargetPath, initialSize) //nolint:errcheck
406405
logger.CommandLog(renameLogSender, fsSourcePath, fsTargetPath, c.User.Username, "", c.ID, c.protocol, -1, -1,
407406
"", "", "", -1)
408-
action := newActionNotification(&c.User, operationRename, fsSourcePath, fsTargetPath, "", c.protocol, 0, nil)
409-
// the returned error is used in test cases only, we already log the error inside action.execute
410-
go actionHandler.Handle(action) // nolint:errcheck
407+
ExecuteActionNotification(&c.User, operationRename, fsSourcePath, fsTargetPath, "", c.protocol, 0, nil)
411408

412409
return nil
413410
}

common/protocol_test.go

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,6 +2186,48 @@ func TestPasswordCaching(t *testing.T) {
21862186
assert.False(t, match)
21872187
}
21882188

2189+
func TestSyncUploadAction(t *testing.T) {
2190+
if runtime.GOOS == osWindows {
2191+
t.Skip("this test is not available on Windows")
2192+
}
2193+
uploadScriptPath := filepath.Join(os.TempDir(), "upload.sh")
2194+
common.Config.Actions.ExecuteOn = []string{"upload"}
2195+
common.Config.Actions.ExecuteSync = []string{"upload"}
2196+
common.Config.Actions.Hook = uploadScriptPath
2197+
2198+
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
2199+
assert.NoError(t, err)
2200+
movedPath := filepath.Join(user.HomeDir, "moved.dat")
2201+
err = os.WriteFile(uploadScriptPath, getUploadScriptContent(movedPath), 0755)
2202+
assert.NoError(t, err)
2203+
conn, client, err := getSftpClient(user)
2204+
if assert.NoError(t, err) {
2205+
defer conn.Close()
2206+
defer client.Close()
2207+
2208+
size := int64(32768)
2209+
err = writeSFTPFileNoCheck(testFileName, size, client)
2210+
assert.NoError(t, err)
2211+
_, err = client.Stat(testFileName)
2212+
assert.Error(t, err)
2213+
info, err := client.Stat(filepath.Base(movedPath))
2214+
if assert.NoError(t, err) {
2215+
assert.Equal(t, size, info.Size())
2216+
}
2217+
}
2218+
2219+
err = os.Remove(uploadScriptPath)
2220+
assert.NoError(t, err)
2221+
_, err = httpdtest.RemoveUser(user, http.StatusOK)
2222+
assert.NoError(t, err)
2223+
err = os.RemoveAll(user.GetHomeDir())
2224+
assert.NoError(t, err)
2225+
2226+
common.Config.Actions.ExecuteOn = nil
2227+
common.Config.Actions.ExecuteSync = nil
2228+
common.Config.Actions.Hook = uploadScriptPath
2229+
}
2230+
21892231
func TestQuotaTrackDisabled(t *testing.T) {
21902232
err := dataprovider.Close()
21912233
assert.NoError(t, err)
@@ -2691,30 +2733,41 @@ func getCryptFsUser() dataprovider.User {
26912733
}
26922734

26932735
func writeSFTPFile(name string, size int64, client *sftp.Client) error {
2694-
content := make([]byte, size)
2695-
_, err := rand.Read(content)
2736+
err := writeSFTPFileNoCheck(name, size, client)
26962737
if err != nil {
26972738
return err
26982739
}
2699-
f, err := client.Create(name)
2740+
info, err := client.Stat(name)
27002741
if err != nil {
27012742
return err
27022743
}
2703-
_, err = io.Copy(f, bytes.NewBuffer(content))
2744+
if info.Size() != size {
2745+
return fmt.Errorf("file size mismatch, wanted %v, actual %v", size, info.Size())
2746+
}
2747+
return nil
2748+
}
2749+
2750+
func writeSFTPFileNoCheck(name string, size int64, client *sftp.Client) error {
2751+
content := make([]byte, size)
2752+
_, err := rand.Read(content)
27042753
if err != nil {
2705-
f.Close()
27062754
return err
27072755
}
2708-
err = f.Close()
2756+
f, err := client.Create(name)
27092757
if err != nil {
27102758
return err
27112759
}
2712-
info, err := client.Stat(name)
2760+
_, err = io.Copy(f, bytes.NewBuffer(content))
27132761
if err != nil {
2762+
f.Close()
27142763
return err
27152764
}
2716-
if info.Size() != size {
2717-
return fmt.Errorf("file size mismatch, wanted %v, actual %v", size, info.Size())
2718-
}
2719-
return nil
2765+
return f.Close()
2766+
}
2767+
2768+
func getUploadScriptContent(movedPath string) []byte {
2769+
content := []byte("#!/bin/sh\n\n")
2770+
content = append(content, []byte("sleep 1\n")...)
2771+
content = append(content, []byte(fmt.Sprintf("mv ${SFTPGO_ACTION_PATH} %v\n", movedPath))...)
2772+
return content
27202773
}

common/transfer.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,8 @@ func (t *BaseTransfer) Close() error {
235235
if t.transferType == TransferDownload {
236236
logger.TransferLog(downloadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesSent), t.Connection.User.Username,
237237
t.Connection.ID, t.Connection.protocol)
238-
action := newActionNotification(&t.Connection.User, operationDownload, t.fsPath, "", "", t.Connection.protocol,
238+
ExecuteActionNotification(&t.Connection.User, operationDownload, t.fsPath, "", "", t.Connection.protocol,
239239
atomic.LoadInt64(&t.BytesSent), t.ErrTransfer)
240-
go actionHandler.Handle(action) //nolint:errcheck
241240
} else {
242241
fileSize := atomic.LoadInt64(&t.BytesReceived) + t.MinWriteOffset
243242
if statSize, err := t.getUploadFileSize(); err == nil {
@@ -247,9 +246,8 @@ func (t *BaseTransfer) Close() error {
247246
t.updateQuota(numFiles, fileSize)
248247
logger.TransferLog(uploadLogSender, t.fsPath, elapsed, atomic.LoadInt64(&t.BytesReceived), t.Connection.User.Username,
249248
t.Connection.ID, t.Connection.protocol)
250-
action := newActionNotification(&t.Connection.User, operationUpload, t.fsPath, "", "", t.Connection.protocol,
251-
fileSize, t.ErrTransfer)
252-
go actionHandler.Handle(action) //nolint:errcheck
249+
ExecuteActionNotification(&t.Connection.User, operationUpload, t.fsPath, "", "", t.Connection.protocol, fileSize,
250+
t.ErrTransfer)
253251
}
254252
if t.ErrTransfer != nil {
255253
t.Connection.Log(logger.LevelWarn, "transfer error: %v, path: %#v", t.ErrTransfer, t.fsPath)

config/config.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ func Init() {
110110
IdleTimeout: 15,
111111
UploadMode: 0,
112112
Actions: common.ProtocolActions{
113-
ExecuteOn: []string{},
114-
Hook: "",
113+
ExecuteOn: []string{},
114+
ExecuteSync: []string{},
115+
Hook: "",
115116
},
116117
SetstatMode: 0,
117118
ProxyProtocol: 0,
@@ -882,6 +883,7 @@ func setViperDefaults() {
882883
viper.SetDefault("common.idle_timeout", globalConf.Common.IdleTimeout)
883884
viper.SetDefault("common.upload_mode", globalConf.Common.UploadMode)
884885
viper.SetDefault("common.actions.execute_on", globalConf.Common.Actions.ExecuteOn)
886+
viper.SetDefault("common.actions.execute_sync", globalConf.Common.Actions.ExecuteSync)
885887
viper.SetDefault("common.actions.hook", globalConf.Common.Actions.Hook)
886888
viper.SetDefault("common.setstat_mode", globalConf.Common.SetstatMode)
887889
viper.SetDefault("common.proxy_protocol", globalConf.Common.ProxyProtocol)

docs/custom-actions.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ If the `hook` defines an HTTP URL then this URL will be invoked as HTTP POST. Th
4848

4949
The HTTP hook will use the global configuration for HTTP clients and will respect the retry configurations.
5050

51-
The `actions` struct inside the "data_provider" configuration section allows you to configure actions on user add, update, delete.
51+
The `pre-delete` action is always executed synchronously while the other ones are asynchronous. You can specify the actions to run synchronously via the `execute_sync` configuration key. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your hook have completed its execution. If your hook takes a long time to complete this could cause a timeout on the client side, which wouldn't receive the server response in a timely manner and eventually drop the connection.
52+
53+
The `actions` struct inside the `data_provider` configuration section allows you to configure actions on user add, update, delete.
5254

5355
Actions will not be fired for internal updates, such as the last login or the user quota fields, or after external authentication.
5456

docs/full-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ The configuration file contains the following sections:
5353
- `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.
5454
- `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
5555
- `execute_on`, list of strings. Valid values are `download`, `upload`, `pre-delete`, `delete`, `rename`, `ssh_cmd`. Leave empty to disable actions.
56+
- `execute_sync`, list of strings. Actions to be performed synchronously. The `pre-delete` action is always executed synchronously while the other ones are asynchronous. Executing an action synchronously means that SFTPGo will not return a result code to the client (which is waiting for it) until your hook have completed its execution. Leave empty to execute only the `pre-delete` hook synchronously
5657
- `hook`, string. Absolute path to the command to execute or HTTP URL to notify.
5758
- `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. 2 means "ignore mode for cloud based filesystems": requests for changing permissions, owner/group and access/modification times are silently ignored for cloud filesystems and executed for local filesystem.
5859
- `proxy_protocol`, integer. Support for [HAProxy PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt). If you are running SFTPGo behind a proxy server such as HAProxy, AWS ELB or NGNIX, you can enable the proxy protocol. It provides a convenient way to safely transport connection information such as a client's address across multiple layers of NAT or TCP proxies to get the real client IP address instead of the proxy IP. Both protocol versions 1 and 2 are supported. If the proxy protocol is enabled in SFTPGo then you have to enable the protocol in your proxy configuration too. For example, for HAProxy, add `send-proxy` or `send-proxy-v2` to each server configuration line. The following modes are supported:

sftpd/ssh_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,7 @@ func (c *sshCommand) sendExitStatus(err error) {
747747
targetPath = p
748748
}
749749
}
750-
common.SSHCommandActionNotification(&c.connection.User, cmdPath, targetPath, c.command, err)
750+
common.ExecuteActionNotification(&c.connection.User, "ssh_cmd", cmdPath, targetPath, c.command, common.ProtocolSSH, 0, err)
751751
}
752752
}
753753

sftpgo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"upload_mode": 0,
55
"actions": {
66
"execute_on": [],
7+
"execute_sync": [],
78
"hook": ""
89
},
910
"setstat_mode": 0,

0 commit comments

Comments
 (0)