actions.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. package common
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "net/url"
  10. "os"
  11. "os/exec"
  12. "path/filepath"
  13. "strings"
  14. "time"
  15. "github.com/drakkan/sftpgo/dataprovider"
  16. "github.com/drakkan/sftpgo/httpclient"
  17. "github.com/drakkan/sftpgo/logger"
  18. "github.com/drakkan/sftpgo/utils"
  19. "github.com/drakkan/sftpgo/vfs"
  20. )
  21. var (
  22. errUnconfiguredAction = errors.New("no hook is configured for this action")
  23. errNoHook = errors.New("unable to execute action, no hook defined")
  24. errUnexpectedHTTResponse = errors.New("unexpected HTTP response code")
  25. )
  26. // ProtocolActions defines the action to execute on file operations and SSH commands
  27. type ProtocolActions struct {
  28. // Valid values are download, upload, pre-delete, delete, rename, ssh_cmd. Empty slice to disable
  29. ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
  30. // Actions to be performed synchronously.
  31. // The pre-delete action is always executed synchronously while the other ones are asynchronous.
  32. // Executing an action synchronously means that SFTPGo will not return a result code to the client
  33. // (which is waiting for it) until your hook have completed its execution.
  34. ExecuteSync []string `json:"execute_sync" mapstructure:"execute_sync"`
  35. // Absolute path to an external program or an HTTP URL
  36. Hook string `json:"hook" mapstructure:"hook"`
  37. }
  38. var actionHandler ActionHandler = &defaultActionHandler{}
  39. // InitializeActionHandler lets the user choose an action handler implementation.
  40. //
  41. // Do NOT call this function after application initialization.
  42. func InitializeActionHandler(handler ActionHandler) {
  43. actionHandler = handler
  44. }
  45. // ExecutePreAction executes a pre-* action and returns the result
  46. func ExecutePreAction(user *dataprovider.User, operation, filePath, virtualPath, protocol string, fileSize int64, openFlags int) error {
  47. if !utils.IsStringInSlice(operation, Config.Actions.ExecuteOn) {
  48. // for pre-delete we execute the internal handling on error, so we must return errUnconfiguredAction.
  49. // Other pre action will deny the operation on error so if we have no configuration we must return
  50. // a nil error
  51. if operation == operationPreDelete {
  52. return errUnconfiguredAction
  53. }
  54. return nil
  55. }
  56. notification := newActionNotification(user, operation, filePath, virtualPath, "", "", protocol, fileSize, openFlags, nil)
  57. return actionHandler.Handle(notification)
  58. }
  59. // ExecuteActionNotification executes the defined hook, if any, for the specified action
  60. func ExecuteActionNotification(user *dataprovider.User, operation, filePath, virtualPath, target, sshCmd, protocol string, fileSize int64, err error) {
  61. notification := newActionNotification(user, operation, filePath, virtualPath, target, sshCmd, protocol, fileSize, 0, err)
  62. if utils.IsStringInSlice(operation, Config.Actions.ExecuteSync) {
  63. actionHandler.Handle(notification) //nolint:errcheck
  64. return
  65. }
  66. go actionHandler.Handle(notification) //nolint:errcheck
  67. }
  68. // ActionHandler handles a notification for a Protocol Action.
  69. type ActionHandler interface {
  70. Handle(notification *ActionNotification) error
  71. }
  72. // ActionNotification defines a notification for a Protocol Action.
  73. type ActionNotification struct {
  74. Action string `json:"action"`
  75. Username string `json:"username"`
  76. Path string `json:"path"`
  77. TargetPath string `json:"target_path,omitempty"`
  78. SSHCmd string `json:"ssh_cmd,omitempty"`
  79. FileSize int64 `json:"file_size,omitempty"`
  80. FsProvider int `json:"fs_provider"`
  81. Bucket string `json:"bucket,omitempty"`
  82. Endpoint string `json:"endpoint,omitempty"`
  83. Status int `json:"status"`
  84. Protocol string `json:"protocol"`
  85. OpenFlags int `json:"open_flags,omitempty"`
  86. }
  87. func newActionNotification(
  88. user *dataprovider.User,
  89. operation, filePath, virtualPath, target, sshCmd, protocol string,
  90. fileSize int64,
  91. openFlags int,
  92. err error,
  93. ) *ActionNotification {
  94. var bucket, endpoint string
  95. status := 1
  96. fsConfig := user.GetFsConfigForPath(virtualPath)
  97. switch fsConfig.Provider {
  98. case vfs.S3FilesystemProvider:
  99. bucket = fsConfig.S3Config.Bucket
  100. endpoint = fsConfig.S3Config.Endpoint
  101. case vfs.GCSFilesystemProvider:
  102. bucket = fsConfig.GCSConfig.Bucket
  103. case vfs.AzureBlobFilesystemProvider:
  104. bucket = fsConfig.AzBlobConfig.Container
  105. if fsConfig.AzBlobConfig.SASURL != "" {
  106. endpoint = fsConfig.AzBlobConfig.SASURL
  107. } else {
  108. endpoint = fsConfig.AzBlobConfig.Endpoint
  109. }
  110. case vfs.SFTPFilesystemProvider:
  111. endpoint = fsConfig.SFTPConfig.Endpoint
  112. }
  113. if err == ErrQuotaExceeded {
  114. status = 2
  115. } else if err != nil {
  116. status = 0
  117. }
  118. return &ActionNotification{
  119. Action: operation,
  120. Username: user.Username,
  121. Path: filePath,
  122. TargetPath: target,
  123. SSHCmd: sshCmd,
  124. FileSize: fileSize,
  125. FsProvider: int(fsConfig.Provider),
  126. Bucket: bucket,
  127. Endpoint: endpoint,
  128. Status: status,
  129. Protocol: protocol,
  130. OpenFlags: openFlags,
  131. }
  132. }
  133. type defaultActionHandler struct{}
  134. func (h *defaultActionHandler) Handle(notification *ActionNotification) error {
  135. if !utils.IsStringInSlice(notification.Action, Config.Actions.ExecuteOn) {
  136. return errUnconfiguredAction
  137. }
  138. if Config.Actions.Hook == "" {
  139. logger.Warn(notification.Protocol, "", "Unable to send notification, no hook is defined")
  140. return errNoHook
  141. }
  142. if strings.HasPrefix(Config.Actions.Hook, "http") {
  143. return h.handleHTTP(notification)
  144. }
  145. return h.handleCommand(notification)
  146. }
  147. func (h *defaultActionHandler) handleHTTP(notification *ActionNotification) error {
  148. u, err := url.Parse(Config.Actions.Hook)
  149. if err != nil {
  150. logger.Warn(notification.Protocol, "", "Invalid hook %#v for operation %#v: %v", Config.Actions.Hook, notification.Action, err)
  151. return err
  152. }
  153. startTime := time.Now()
  154. respCode := 0
  155. var b bytes.Buffer
  156. _ = json.NewEncoder(&b).Encode(notification)
  157. resp, err := httpclient.RetryablePost(Config.Actions.Hook, "application/json", &b)
  158. if err == nil {
  159. respCode = resp.StatusCode
  160. resp.Body.Close()
  161. if respCode != http.StatusOK {
  162. err = errUnexpectedHTTResponse
  163. }
  164. }
  165. logger.Debug(notification.Protocol, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v",
  166. notification.Action, u.Redacted(), respCode, time.Since(startTime), err)
  167. return err
  168. }
  169. func (h *defaultActionHandler) handleCommand(notification *ActionNotification) error {
  170. if !filepath.IsAbs(Config.Actions.Hook) {
  171. err := fmt.Errorf("invalid notification command %#v", Config.Actions.Hook)
  172. logger.Warn(notification.Protocol, "", "unable to execute notification command: %v", err)
  173. return err
  174. }
  175. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  176. defer cancel()
  177. cmd := exec.CommandContext(ctx, Config.Actions.Hook, notification.Action, notification.Username, notification.Path, notification.TargetPath, notification.SSHCmd)
  178. cmd.Env = append(os.Environ(), notificationAsEnvVars(notification)...)
  179. startTime := time.Now()
  180. err := cmd.Run()
  181. logger.Debug(notification.Protocol, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v",
  182. Config.Actions.Hook, notification.Action, notification.Username, notification.Path, notification.TargetPath, notification.SSHCmd, time.Since(startTime), err)
  183. return err
  184. }
  185. func notificationAsEnvVars(notification *ActionNotification) []string {
  186. return []string{
  187. fmt.Sprintf("SFTPGO_ACTION=%v", notification.Action),
  188. fmt.Sprintf("SFTPGO_ACTION_USERNAME=%v", notification.Username),
  189. fmt.Sprintf("SFTPGO_ACTION_PATH=%v", notification.Path),
  190. fmt.Sprintf("SFTPGO_ACTION_TARGET=%v", notification.TargetPath),
  191. fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%v", notification.SSHCmd),
  192. fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%v", notification.FileSize),
  193. fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%v", notification.FsProvider),
  194. fmt.Sprintf("SFTPGO_ACTION_BUCKET=%v", notification.Bucket),
  195. fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%v", notification.Endpoint),
  196. fmt.Sprintf("SFTPGO_ACTION_STATUS=%v", notification.Status),
  197. fmt.Sprintf("SFTPGO_ACTION_PROTOCOL=%v", notification.Protocol),
  198. fmt.Sprintf("SFTPGO_ACTION_OPEN_FLAGS=%v", notification.OpenFlags),
  199. }
  200. }