actions.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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. // Absolute path to an external program or an HTTP URL
  31. Hook string `json:"hook" mapstructure:"hook"`
  32. }
  33. var actionHandler ActionHandler = &defaultActionHandler{}
  34. // InitializeActionHandler lets the user choose an action handler implementation.
  35. //
  36. // Do NOT call this function after application initialization.
  37. func InitializeActionHandler(handler ActionHandler) {
  38. actionHandler = handler
  39. }
  40. // SSHCommandActionNotification executes the defined action for the specified SSH command.
  41. func SSHCommandActionNotification(user *dataprovider.User, filePath, target, sshCmd string, err error) {
  42. notification := newActionNotification(user, operationSSHCmd, filePath, target, sshCmd, ProtocolSSH, 0, err)
  43. go actionHandler.Handle(notification) // nolint:errcheck
  44. }
  45. // ActionHandler handles a notification for a Protocol Action.
  46. type ActionHandler interface {
  47. Handle(notification *ActionNotification) error
  48. }
  49. // ActionNotification defines a notification for a Protocol Action.
  50. type ActionNotification struct {
  51. Action string `json:"action"`
  52. Username string `json:"username"`
  53. Path string `json:"path"`
  54. TargetPath string `json:"target_path,omitempty"`
  55. SSHCmd string `json:"ssh_cmd,omitempty"`
  56. FileSize int64 `json:"file_size,omitempty"`
  57. FsProvider int `json:"fs_provider"`
  58. Bucket string `json:"bucket,omitempty"`
  59. Endpoint string `json:"endpoint,omitempty"`
  60. Status int `json:"status"`
  61. Protocol string `json:"protocol"`
  62. }
  63. func newActionNotification(
  64. user *dataprovider.User,
  65. operation, filePath, target, sshCmd, protocol string,
  66. fileSize int64,
  67. err error,
  68. ) *ActionNotification {
  69. var bucket, endpoint string
  70. status := 1
  71. if user.FsConfig.Provider == vfs.S3FilesystemProvider {
  72. bucket = user.FsConfig.S3Config.Bucket
  73. endpoint = user.FsConfig.S3Config.Endpoint
  74. } else if user.FsConfig.Provider == vfs.GCSFilesystemProvider {
  75. bucket = user.FsConfig.GCSConfig.Bucket
  76. } else if user.FsConfig.Provider == vfs.AzureBlobFilesystemProvider {
  77. bucket = user.FsConfig.AzBlobConfig.Container
  78. if user.FsConfig.AzBlobConfig.SASURL != "" {
  79. endpoint = user.FsConfig.AzBlobConfig.SASURL
  80. } else {
  81. endpoint = user.FsConfig.AzBlobConfig.Endpoint
  82. }
  83. }
  84. if err == ErrQuotaExceeded {
  85. status = 2
  86. } else if err != nil {
  87. status = 0
  88. }
  89. return &ActionNotification{
  90. Action: operation,
  91. Username: user.Username,
  92. Path: filePath,
  93. TargetPath: target,
  94. SSHCmd: sshCmd,
  95. FileSize: fileSize,
  96. FsProvider: int(user.FsConfig.Provider),
  97. Bucket: bucket,
  98. Endpoint: endpoint,
  99. Status: status,
  100. Protocol: protocol,
  101. }
  102. }
  103. type defaultActionHandler struct{}
  104. func (h *defaultActionHandler) Handle(notification *ActionNotification) error {
  105. if !utils.IsStringInSlice(notification.Action, Config.Actions.ExecuteOn) {
  106. return errUnconfiguredAction
  107. }
  108. if Config.Actions.Hook == "" {
  109. logger.Warn(notification.Protocol, "", "Unable to send notification, no hook is defined")
  110. return errNoHook
  111. }
  112. if strings.HasPrefix(Config.Actions.Hook, "http") {
  113. return h.handleHTTP(notification)
  114. }
  115. return h.handleCommand(notification)
  116. }
  117. func (h *defaultActionHandler) handleHTTP(notification *ActionNotification) error {
  118. u, err := url.Parse(Config.Actions.Hook)
  119. if err != nil {
  120. logger.Warn(notification.Protocol, "", "Invalid hook %#v for operation %#v: %v", Config.Actions.Hook, notification.Action, err)
  121. return err
  122. }
  123. startTime := time.Now()
  124. respCode := 0
  125. httpClient := httpclient.GetRetraybleHTTPClient()
  126. var b bytes.Buffer
  127. _ = json.NewEncoder(&b).Encode(notification)
  128. resp, err := httpClient.Post(u.String(), "application/json", &b)
  129. if err == nil {
  130. respCode = resp.StatusCode
  131. resp.Body.Close()
  132. if respCode != http.StatusOK {
  133. err = errUnexpectedHTTResponse
  134. }
  135. }
  136. logger.Debug(notification.Protocol, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v", notification.Action, u.String(), respCode, time.Since(startTime), err)
  137. return err
  138. }
  139. func (h *defaultActionHandler) handleCommand(notification *ActionNotification) error {
  140. if !filepath.IsAbs(Config.Actions.Hook) {
  141. err := fmt.Errorf("invalid notification command %#v", Config.Actions.Hook)
  142. logger.Warn(notification.Protocol, "", "unable to execute notification command: %v", err)
  143. return err
  144. }
  145. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  146. defer cancel()
  147. cmd := exec.CommandContext(ctx, Config.Actions.Hook, notification.Action, notification.Username, notification.Path, notification.TargetPath, notification.SSHCmd)
  148. cmd.Env = append(os.Environ(), notificationAsEnvVars(notification)...)
  149. startTime := time.Now()
  150. err := cmd.Run()
  151. logger.Debug(notification.Protocol, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v",
  152. Config.Actions.Hook, notification.Action, notification.Username, notification.Path, notification.TargetPath, notification.SSHCmd, time.Since(startTime), err)
  153. return err
  154. }
  155. func notificationAsEnvVars(notification *ActionNotification) []string {
  156. return []string{
  157. fmt.Sprintf("SFTPGO_ACTION=%v", notification.Action),
  158. fmt.Sprintf("SFTPGO_ACTION_USERNAME=%v", notification.Username),
  159. fmt.Sprintf("SFTPGO_ACTION_PATH=%v", notification.Path),
  160. fmt.Sprintf("SFTPGO_ACTION_TARGET=%v", notification.TargetPath),
  161. fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%v", notification.SSHCmd),
  162. fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%v", notification.FileSize),
  163. fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%v", notification.FsProvider),
  164. fmt.Sprintf("SFTPGO_ACTION_BUCKET=%v", notification.Bucket),
  165. fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%v", notification.Endpoint),
  166. fmt.Sprintf("SFTPGO_ACTION_STATUS=%v", notification.Status),
  167. fmt.Sprintf("SFTPGO_ACTION_PROTOCOL=%v", notification.Protocol),
  168. }
  169. }