actions.go 11 KB

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