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