actions.go 10 KB

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