actions.go 8.3 KB

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