actions.go 8.9 KB

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