actions.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. package common
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "net/url"
  10. "os/exec"
  11. "path"
  12. "path/filepath"
  13. "strings"
  14. "time"
  15. "github.com/sftpgo/sdk"
  16. "github.com/sftpgo/sdk/plugin/notifier"
  17. "github.com/drakkan/sftpgo/v2/command"
  18. "github.com/drakkan/sftpgo/v2/dataprovider"
  19. "github.com/drakkan/sftpgo/v2/httpclient"
  20. "github.com/drakkan/sftpgo/v2/logger"
  21. "github.com/drakkan/sftpgo/v2/plugin"
  22. "github.com/drakkan/sftpgo/v2/util"
  23. )
  24. var (
  25. errUnconfiguredAction = errors.New("no hook is configured for this action")
  26. errNoHook = errors.New("unable to execute action, no hook defined")
  27. errUnexpectedHTTResponse = errors.New("unexpected HTTP hook response code")
  28. )
  29. // ProtocolActions defines the action to execute on file operations and SSH commands
  30. type ProtocolActions struct {
  31. // Valid values are download, upload, pre-delete, delete, rename, ssh_cmd. Empty slice to disable
  32. ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
  33. // Actions to be performed synchronously.
  34. // The pre-delete action is always executed synchronously while the other ones are asynchronous.
  35. // Executing an action synchronously means that SFTPGo will not return a result code to the client
  36. // (which is waiting for it) until your hook have completed its execution.
  37. ExecuteSync []string `json:"execute_sync" mapstructure:"execute_sync"`
  38. // Absolute path to an external program or an HTTP URL
  39. Hook string `json:"hook" mapstructure:"hook"`
  40. }
  41. var actionHandler ActionHandler = &defaultActionHandler{}
  42. // InitializeActionHandler lets the user choose an action handler implementation.
  43. //
  44. // Do NOT call this function after application initialization.
  45. func InitializeActionHandler(handler ActionHandler) {
  46. actionHandler = handler
  47. }
  48. func handleUnconfiguredPreAction(operation string) error {
  49. // for pre-delete we execute the internal handling on error, so we must return errUnconfiguredAction.
  50. // Other pre action will deny the operation on error so if we have no configuration we must return
  51. // a nil error
  52. if operation == operationPreDelete {
  53. return errUnconfiguredAction
  54. }
  55. return nil
  56. }
  57. // ExecutePreAction executes a pre-* action and returns the result
  58. func ExecutePreAction(conn *BaseConnection, operation, filePath, virtualPath string, fileSize int64, openFlags int) error {
  59. var event *notifier.FsEvent
  60. hasNotifiersPlugin := plugin.Handler.HasNotifiers()
  61. hasHook := util.Contains(Config.Actions.ExecuteOn, operation)
  62. if !hasHook && !hasNotifiersPlugin {
  63. return handleUnconfiguredPreAction(operation)
  64. }
  65. event = newActionNotification(&conn.User, operation, filePath, virtualPath, "", "", "",
  66. conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, openFlags, nil)
  67. if hasNotifiersPlugin {
  68. plugin.Handler.NotifyFsEvent(event)
  69. }
  70. if !hasHook {
  71. return handleUnconfiguredPreAction(operation)
  72. }
  73. return actionHandler.Handle(event)
  74. }
  75. // ExecuteActionNotification executes the defined hook, if any, for the specified action
  76. func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtualPath, target, virtualTarget, sshCmd string,
  77. fileSize int64, err error,
  78. ) error {
  79. hasNotifiersPlugin := plugin.Handler.HasNotifiers()
  80. hasHook := util.Contains(Config.Actions.ExecuteOn, operation)
  81. hasRules := dataprovider.EventManager.HasFsRules()
  82. if !hasHook && !hasNotifiersPlugin && !hasRules {
  83. return nil
  84. }
  85. notification := newActionNotification(&conn.User, operation, filePath, virtualPath, target, virtualTarget, sshCmd,
  86. conn.protocol, conn.GetRemoteIP(), conn.ID, fileSize, 0, err)
  87. if hasNotifiersPlugin {
  88. plugin.Handler.NotifyFsEvent(notification)
  89. }
  90. var errRes error
  91. if hasRules {
  92. errRes = dataprovider.EventManager.HandleFsEvent(dataprovider.EventParams{
  93. Name: notification.Username,
  94. Event: notification.Action,
  95. Status: notification.Status,
  96. VirtualPath: notification.VirtualPath,
  97. FsPath: notification.Path,
  98. VirtualTargetPath: notification.VirtualTargetPath,
  99. FsTargetPath: notification.TargetPath,
  100. ObjectName: path.Base(notification.VirtualPath),
  101. FileSize: notification.FileSize,
  102. Protocol: notification.Protocol,
  103. IP: notification.IP,
  104. Timestamp: notification.Timestamp,
  105. Object: nil,
  106. })
  107. }
  108. if hasHook {
  109. if util.Contains(Config.Actions.ExecuteSync, operation) {
  110. if errHook := actionHandler.Handle(notification); errHook != nil {
  111. errRes = errHook
  112. }
  113. } else {
  114. go actionHandler.Handle(notification) //nolint:errcheck
  115. }
  116. }
  117. return errRes
  118. }
  119. // ActionHandler handles a notification for a Protocol Action.
  120. type ActionHandler interface {
  121. Handle(notification *notifier.FsEvent) error
  122. }
  123. func newActionNotification(
  124. user *dataprovider.User,
  125. operation, filePath, virtualPath, target, virtualTarget, sshCmd, protocol, ip, sessionID string,
  126. fileSize int64,
  127. openFlags int,
  128. err error,
  129. ) *notifier.FsEvent {
  130. var bucket, endpoint string
  131. fsConfig := user.GetFsConfigForPath(virtualPath)
  132. switch fsConfig.Provider {
  133. case sdk.S3FilesystemProvider:
  134. bucket = fsConfig.S3Config.Bucket
  135. endpoint = fsConfig.S3Config.Endpoint
  136. case sdk.GCSFilesystemProvider:
  137. bucket = fsConfig.GCSConfig.Bucket
  138. case sdk.AzureBlobFilesystemProvider:
  139. bucket = fsConfig.AzBlobConfig.Container
  140. if fsConfig.AzBlobConfig.Endpoint != "" {
  141. endpoint = fsConfig.AzBlobConfig.Endpoint
  142. }
  143. case sdk.SFTPFilesystemProvider:
  144. endpoint = fsConfig.SFTPConfig.Endpoint
  145. case sdk.HTTPFilesystemProvider:
  146. endpoint = fsConfig.HTTPConfig.Endpoint
  147. }
  148. return &notifier.FsEvent{
  149. Action: operation,
  150. Username: user.Username,
  151. Path: filePath,
  152. TargetPath: target,
  153. VirtualPath: virtualPath,
  154. VirtualTargetPath: virtualTarget,
  155. SSHCmd: sshCmd,
  156. FileSize: fileSize,
  157. FsProvider: int(fsConfig.Provider),
  158. Bucket: bucket,
  159. Endpoint: endpoint,
  160. Status: getNotificationStatus(err),
  161. Protocol: protocol,
  162. IP: ip,
  163. SessionID: sessionID,
  164. OpenFlags: openFlags,
  165. Timestamp: time.Now().UnixNano(),
  166. }
  167. }
  168. type defaultActionHandler struct{}
  169. func (h *defaultActionHandler) Handle(event *notifier.FsEvent) error {
  170. if !util.Contains(Config.Actions.ExecuteOn, event.Action) {
  171. return errUnconfiguredAction
  172. }
  173. if Config.Actions.Hook == "" {
  174. logger.Warn(event.Protocol, "", "Unable to send notification, no hook is defined")
  175. return errNoHook
  176. }
  177. if strings.HasPrefix(Config.Actions.Hook, "http") {
  178. return h.handleHTTP(event)
  179. }
  180. return h.handleCommand(event)
  181. }
  182. func (h *defaultActionHandler) handleHTTP(event *notifier.FsEvent) error {
  183. u, err := url.Parse(Config.Actions.Hook)
  184. if err != nil {
  185. logger.Error(event.Protocol, "", "Invalid hook %#v for operation %#v: %v",
  186. Config.Actions.Hook, event.Action, err)
  187. return err
  188. }
  189. startTime := time.Now()
  190. respCode := 0
  191. var b bytes.Buffer
  192. _ = json.NewEncoder(&b).Encode(event)
  193. resp, err := httpclient.RetryablePost(Config.Actions.Hook, "application/json", &b)
  194. if err == nil {
  195. respCode = resp.StatusCode
  196. resp.Body.Close()
  197. if respCode != http.StatusOK {
  198. err = errUnexpectedHTTResponse
  199. }
  200. }
  201. logger.Debug(event.Protocol, "", "notified operation %q to URL: %s status code: %d, elapsed: %s err: %v",
  202. event.Action, u.Redacted(), respCode, time.Since(startTime), err)
  203. return err
  204. }
  205. func (h *defaultActionHandler) handleCommand(event *notifier.FsEvent) error {
  206. if !filepath.IsAbs(Config.Actions.Hook) {
  207. err := fmt.Errorf("invalid notification command %#v", Config.Actions.Hook)
  208. logger.Warn(event.Protocol, "", "unable to execute notification command: %v", err)
  209. return err
  210. }
  211. timeout, env := command.GetConfig(Config.Actions.Hook)
  212. ctx, cancel := context.WithTimeout(context.Background(), timeout)
  213. defer cancel()
  214. cmd := exec.CommandContext(ctx, Config.Actions.Hook)
  215. cmd.Env = append(env, notificationAsEnvVars(event)...)
  216. startTime := time.Now()
  217. err := cmd.Run()
  218. logger.Debug(event.Protocol, "", "executed command %#v, elapsed: %v, error: %v",
  219. Config.Actions.Hook, time.Since(startTime), err)
  220. return err
  221. }
  222. func notificationAsEnvVars(event *notifier.FsEvent) []string {
  223. return []string{
  224. fmt.Sprintf("SFTPGO_ACTION=%s", event.Action),
  225. fmt.Sprintf("SFTPGO_ACTION_USERNAME=%s", event.Username),
  226. fmt.Sprintf("SFTPGO_ACTION_PATH=%s", event.Path),
  227. fmt.Sprintf("SFTPGO_ACTION_TARGET=%s", event.TargetPath),
  228. fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_PATH=%s", event.VirtualPath),
  229. fmt.Sprintf("SFTPGO_ACTION_VIRTUAL_TARGET=%s", event.VirtualTargetPath),
  230. fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%s", event.SSHCmd),
  231. fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%d", event.FileSize),
  232. fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%d", event.FsProvider),
  233. fmt.Sprintf("SFTPGO_ACTION_BUCKET=%s", event.Bucket),
  234. fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%s", event.Endpoint),
  235. fmt.Sprintf("SFTPGO_ACTION_STATUS=%d", event.Status),
  236. fmt.Sprintf("SFTPGO_ACTION_PROTOCOL=%s", event.Protocol),
  237. fmt.Sprintf("SFTPGO_ACTION_IP=%s", event.IP),
  238. fmt.Sprintf("SFTPGO_ACTION_SESSION_ID=%s", event.SessionID),
  239. fmt.Sprintf("SFTPGO_ACTION_OPEN_FLAGS=%d", event.OpenFlags),
  240. fmt.Sprintf("SFTPGO_ACTION_TIMESTAMP=%d", event.Timestamp),
  241. }
  242. }
  243. func getNotificationStatus(err error) int {
  244. status := 1
  245. if err == ErrQuotaExceeded {
  246. status = 3
  247. } else if err != nil {
  248. status = 2
  249. }
  250. return status
  251. }