sftpd.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. // Package sftpd implements the SSH File Transfer Protocol as described in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02.
  2. // It uses pkg/sftp library:
  3. // https://github.com/pkg/sftp
  4. package sftpd
  5. import (
  6. "bytes"
  7. "context"
  8. "encoding/json"
  9. "errors"
  10. "fmt"
  11. "net/http"
  12. "net/url"
  13. "os"
  14. "os/exec"
  15. "path/filepath"
  16. "strings"
  17. "sync"
  18. "time"
  19. "github.com/drakkan/sftpgo/dataprovider"
  20. "github.com/drakkan/sftpgo/httpclient"
  21. "github.com/drakkan/sftpgo/logger"
  22. "github.com/drakkan/sftpgo/metrics"
  23. "github.com/drakkan/sftpgo/utils"
  24. )
  25. const (
  26. logSender = "sftpd"
  27. logSenderSCP = "scp"
  28. logSenderSSH = "ssh"
  29. uploadLogSender = "Upload"
  30. downloadLogSender = "Download"
  31. renameLogSender = "Rename"
  32. rmdirLogSender = "Rmdir"
  33. mkdirLogSender = "Mkdir"
  34. symlinkLogSender = "Symlink"
  35. removeLogSender = "Remove"
  36. chownLogSender = "Chown"
  37. chmodLogSender = "Chmod"
  38. chtimesLogSender = "Chtimes"
  39. sshCommandLogSender = "SSHCommand"
  40. operationDownload = "download"
  41. operationUpload = "upload"
  42. operationDelete = "delete"
  43. operationPreDelete = "pre-delete"
  44. operationRename = "rename"
  45. operationSSHCmd = "ssh_cmd"
  46. protocolSFTP = "SFTP"
  47. protocolSCP = "SCP"
  48. protocolSSH = "SSH"
  49. handshakeTimeout = 2 * time.Minute
  50. )
  51. const (
  52. uploadModeStandard = iota
  53. uploadModeAtomic
  54. uploadModeAtomicWithResume
  55. )
  56. var (
  57. mutex sync.RWMutex
  58. openConnections map[string]Connection
  59. activeTransfers []*Transfer
  60. idleTimeout time.Duration
  61. activeQuotaScans []ActiveQuotaScan
  62. activeVFoldersQuotaScan []ActiveVirtualFolderQuotaScan
  63. dataProvider dataprovider.Provider
  64. actions Actions
  65. uploadMode int
  66. setstatMode int
  67. supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd",
  68. "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
  69. defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"}
  70. sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
  71. systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"}
  72. errUnconfiguredAction = errors.New("no hook is configured for this action")
  73. errNoHook = errors.New("unable to execute action, no hook defined")
  74. errUnexpectedHTTResponse = errors.New("unexpected HTTP response code")
  75. )
  76. type connectionTransfer struct {
  77. OperationType string `json:"operation_type"`
  78. StartTime int64 `json:"start_time"`
  79. Size int64 `json:"size"`
  80. LastActivity int64 `json:"last_activity"`
  81. Path string `json:"path"`
  82. }
  83. // ActiveQuotaScan defines an active quota scan for a user home dir
  84. type ActiveQuotaScan struct {
  85. // Username to which the quota scan refers
  86. Username string `json:"username"`
  87. // quota scan start time as unix timestamp in milliseconds
  88. StartTime int64 `json:"start_time"`
  89. }
  90. // ActiveVirtualFolderQuotaScan defines an active quota scan for a virtual folder
  91. type ActiveVirtualFolderQuotaScan struct {
  92. // folder path to which the quota scan refers
  93. MappedPath string `json:"mapped_path"`
  94. // quota scan start time as unix timestamp in milliseconds
  95. StartTime int64 `json:"start_time"`
  96. }
  97. // Actions to execute on SFTP create, download, delete and rename.
  98. // An external command can be executed and/or an HTTP notification can be fired
  99. type Actions struct {
  100. // Valid values are download, upload, delete, rename, ssh_cmd. Empty slice to disable
  101. ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
  102. // Deprecated: please use Hook
  103. Command string `json:"command" mapstructure:"command"`
  104. // Deprecated: please use Hook
  105. HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
  106. // Absolute path to an external program or an HTTP URL
  107. Hook string `json:"hook" mapstructure:"hook"`
  108. }
  109. // ConnectionStatus status for an active connection
  110. type ConnectionStatus struct {
  111. // Logged in username
  112. Username string `json:"username"`
  113. // Unique identifier for the connection
  114. ConnectionID string `json:"connection_id"`
  115. // client's version string
  116. ClientVersion string `json:"client_version"`
  117. // Remote address for this connection
  118. RemoteAddress string `json:"remote_address"`
  119. // Connection time as unix timestamp in milliseconds
  120. ConnectionTime int64 `json:"connection_time"`
  121. // Last activity as unix timestamp in milliseconds
  122. LastActivity int64 `json:"last_activity"`
  123. // Protocol for this connection: SFTP, SCP, SSH
  124. Protocol string `json:"protocol"`
  125. // active uploads/downloads
  126. Transfers []connectionTransfer `json:"active_transfers"`
  127. // for protocol SSH this is the issued command
  128. SSHCommand string `json:"ssh_command"`
  129. }
  130. type sshSubsystemExitStatus struct {
  131. Status uint32
  132. }
  133. type sshSubsystemExecMsg struct {
  134. Command string
  135. }
  136. type actionNotification struct {
  137. Action string `json:"action"`
  138. Username string `json:"username"`
  139. Path string `json:"path"`
  140. TargetPath string `json:"target_path,omitempty"`
  141. SSHCmd string `json:"ssh_cmd,omitempty"`
  142. FileSize int64 `json:"file_size,omitempty"`
  143. FsProvider int `json:"fs_provider"`
  144. Bucket string `json:"bucket,omitempty"`
  145. Endpoint string `json:"endpoint,omitempty"`
  146. Status int `json:"status"`
  147. }
  148. func newActionNotification(user dataprovider.User, operation, filePath, target, sshCmd string, fileSize int64,
  149. err error) actionNotification {
  150. bucket := ""
  151. endpoint := ""
  152. status := 1
  153. if user.FsConfig.Provider == 1 {
  154. bucket = user.FsConfig.S3Config.Bucket
  155. endpoint = user.FsConfig.S3Config.Endpoint
  156. } else if user.FsConfig.Provider == 2 {
  157. bucket = user.FsConfig.GCSConfig.Bucket
  158. }
  159. if err != nil {
  160. status = 0
  161. }
  162. return actionNotification{
  163. Action: operation,
  164. Username: user.Username,
  165. Path: filePath,
  166. TargetPath: target,
  167. SSHCmd: sshCmd,
  168. FileSize: fileSize,
  169. FsProvider: user.FsConfig.Provider,
  170. Bucket: bucket,
  171. Endpoint: endpoint,
  172. Status: status,
  173. }
  174. }
  175. func (a *actionNotification) AsJSON() []byte {
  176. res, _ := json.Marshal(a)
  177. return res
  178. }
  179. func (a *actionNotification) AsEnvVars() []string {
  180. return []string{fmt.Sprintf("SFTPGO_ACTION=%v", a.Action),
  181. fmt.Sprintf("SFTPGO_ACTION_USERNAME=%v", a.Username),
  182. fmt.Sprintf("SFTPGO_ACTION_PATH=%v", a.Path),
  183. fmt.Sprintf("SFTPGO_ACTION_TARGET=%v", a.TargetPath),
  184. fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%v", a.SSHCmd),
  185. fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%v", a.FileSize),
  186. fmt.Sprintf("SFTPGO_ACTION_FS_PROVIDER=%v", a.FsProvider),
  187. fmt.Sprintf("SFTPGO_ACTION_BUCKET=%v", a.Bucket),
  188. fmt.Sprintf("SFTPGO_ACTION_ENDPOINT=%v", a.Endpoint),
  189. fmt.Sprintf("SFTPGO_ACTION_STATUS=%v", a.Status),
  190. }
  191. }
  192. func init() {
  193. openConnections = make(map[string]Connection)
  194. }
  195. // GetDefaultSSHCommands returns the SSH commands enabled as default
  196. func GetDefaultSSHCommands() []string {
  197. result := make([]string, len(defaultSSHCommands))
  198. copy(result, defaultSSHCommands)
  199. return result
  200. }
  201. // GetSupportedSSHCommands returns the supported SSH commands
  202. func GetSupportedSSHCommands() []string {
  203. result := make([]string, len(supportedSSHCommands))
  204. copy(result, supportedSSHCommands)
  205. return result
  206. }
  207. // GetConnectionDuration returns the connection duration as string
  208. func (c ConnectionStatus) GetConnectionDuration() string {
  209. elapsed := time.Since(utils.GetTimeFromMsecSinceEpoch(c.ConnectionTime))
  210. return utils.GetDurationAsString(elapsed)
  211. }
  212. // GetConnectionInfo returns connection info.
  213. // Protocol,Client Version and RemoteAddress are returned.
  214. // For SSH commands the issued command is returned too.
  215. func (c ConnectionStatus) GetConnectionInfo() string {
  216. result := fmt.Sprintf("%v. Client: %#v From: %#v", c.Protocol, c.ClientVersion, c.RemoteAddress)
  217. if c.Protocol == protocolSSH && len(c.SSHCommand) > 0 {
  218. result += fmt.Sprintf(". Command: %#v", c.SSHCommand)
  219. }
  220. return result
  221. }
  222. // GetTransfersAsString returns the active transfers as string
  223. func (c ConnectionStatus) GetTransfersAsString() string {
  224. result := ""
  225. for _, t := range c.Transfers {
  226. if len(result) > 0 {
  227. result += ". "
  228. }
  229. result += t.getConnectionTransferAsString()
  230. }
  231. return result
  232. }
  233. func (t connectionTransfer) getConnectionTransferAsString() string {
  234. result := ""
  235. if t.OperationType == operationUpload {
  236. result += "UL"
  237. } else {
  238. result += "DL"
  239. }
  240. result += fmt.Sprintf(" %#v ", t.Path)
  241. if t.Size > 0 {
  242. elapsed := time.Since(utils.GetTimeFromMsecSinceEpoch(t.StartTime))
  243. speed := float64(t.Size) / float64(utils.GetTimeAsMsSinceEpoch(time.Now())-t.StartTime)
  244. result += fmt.Sprintf("Size: %#v Elapsed: %#v Speed: \"%.1f KB/s\"", utils.ByteCountSI(t.Size),
  245. utils.GetDurationAsString(elapsed), speed)
  246. }
  247. return result
  248. }
  249. // SetDataProvider sets the data provider to use to authenticate users and to get/update their disk quota
  250. func SetDataProvider(provider dataprovider.Provider) {
  251. dataProvider = provider
  252. }
  253. func getActiveSessions(username string) int {
  254. mutex.RLock()
  255. defer mutex.RUnlock()
  256. numSessions := 0
  257. for _, c := range openConnections {
  258. if c.User.Username == username {
  259. numSessions++
  260. }
  261. }
  262. return numSessions
  263. }
  264. // GetQuotaScans returns the active quota scans for users home directories
  265. func GetQuotaScans() []ActiveQuotaScan {
  266. mutex.RLock()
  267. defer mutex.RUnlock()
  268. scans := make([]ActiveQuotaScan, len(activeQuotaScans))
  269. copy(scans, activeQuotaScans)
  270. return scans
  271. }
  272. // AddQuotaScan add a user to the ones with active quota scans.
  273. // Returns false if the user has a quota scan already running
  274. func AddQuotaScan(username string) bool {
  275. mutex.Lock()
  276. defer mutex.Unlock()
  277. for _, s := range activeQuotaScans {
  278. if s.Username == username {
  279. return false
  280. }
  281. }
  282. activeQuotaScans = append(activeQuotaScans, ActiveQuotaScan{
  283. Username: username,
  284. StartTime: utils.GetTimeAsMsSinceEpoch(time.Now()),
  285. })
  286. return true
  287. }
  288. // RemoveQuotaScan removes a user from the ones with active quota scans
  289. func RemoveQuotaScan(username string) error {
  290. mutex.Lock()
  291. defer mutex.Unlock()
  292. var err error
  293. indexToRemove := -1
  294. for i, s := range activeQuotaScans {
  295. if s.Username == username {
  296. indexToRemove = i
  297. break
  298. }
  299. }
  300. if indexToRemove >= 0 {
  301. activeQuotaScans[indexToRemove] = activeQuotaScans[len(activeQuotaScans)-1]
  302. activeQuotaScans = activeQuotaScans[:len(activeQuotaScans)-1]
  303. } else {
  304. err = fmt.Errorf("quota scan to remove not found for user: %#v", username)
  305. logger.Warn(logSender, "", "error: %v", err)
  306. }
  307. return err
  308. }
  309. // GetVFoldersQuotaScans returns the active quota scans for virtual folders
  310. func GetVFoldersQuotaScans() []ActiveVirtualFolderQuotaScan {
  311. mutex.RLock()
  312. defer mutex.RUnlock()
  313. scans := make([]ActiveVirtualFolderQuotaScan, len(activeVFoldersQuotaScan))
  314. copy(scans, activeVFoldersQuotaScan)
  315. return scans
  316. }
  317. // AddVFolderQuotaScan add a virtual folder to the ones with active quota scans.
  318. // Returns false if the folder has a quota scan already running
  319. func AddVFolderQuotaScan(folderPath string) bool {
  320. mutex.Lock()
  321. defer mutex.Unlock()
  322. for _, s := range activeVFoldersQuotaScan {
  323. if s.MappedPath == folderPath {
  324. return false
  325. }
  326. }
  327. activeVFoldersQuotaScan = append(activeVFoldersQuotaScan, ActiveVirtualFolderQuotaScan{
  328. MappedPath: folderPath,
  329. StartTime: utils.GetTimeAsMsSinceEpoch(time.Now()),
  330. })
  331. return true
  332. }
  333. // RemoveVFolderQuotaScan removes a folder from the ones with active quota scans
  334. func RemoveVFolderQuotaScan(folderPath string) error {
  335. mutex.Lock()
  336. defer mutex.Unlock()
  337. var err error
  338. indexToRemove := -1
  339. for i, s := range activeVFoldersQuotaScan {
  340. if s.MappedPath == folderPath {
  341. indexToRemove = i
  342. break
  343. }
  344. }
  345. if indexToRemove >= 0 {
  346. activeVFoldersQuotaScan[indexToRemove] = activeVFoldersQuotaScan[len(activeVFoldersQuotaScan)-1]
  347. activeVFoldersQuotaScan = activeVFoldersQuotaScan[:len(activeVFoldersQuotaScan)-1]
  348. } else {
  349. err = fmt.Errorf("quota scan to remove not found for user: %#v", folderPath)
  350. logger.Warn(logSender, "", "error: %v", err)
  351. }
  352. return err
  353. }
  354. // CloseActiveConnection closes an active SFTP connection.
  355. // It returns true on success
  356. func CloseActiveConnection(connectionID string) bool {
  357. result := false
  358. mutex.RLock()
  359. defer mutex.RUnlock()
  360. if c, ok := openConnections[connectionID]; ok {
  361. err := c.close()
  362. c.Log(logger.LevelDebug, logSender, "close connection requested, close err: %v", err)
  363. result = true
  364. }
  365. return result
  366. }
  367. // GetConnectionsStats returns stats for active connections
  368. func GetConnectionsStats() []ConnectionStatus {
  369. mutex.RLock()
  370. defer mutex.RUnlock()
  371. stats := []ConnectionStatus{}
  372. for _, c := range openConnections {
  373. conn := ConnectionStatus{
  374. Username: c.User.Username,
  375. ConnectionID: c.ID,
  376. ClientVersion: c.ClientVersion,
  377. RemoteAddress: c.RemoteAddr.String(),
  378. ConnectionTime: utils.GetTimeAsMsSinceEpoch(c.StartTime),
  379. LastActivity: utils.GetTimeAsMsSinceEpoch(c.lastActivity),
  380. Protocol: c.protocol,
  381. Transfers: []connectionTransfer{},
  382. SSHCommand: c.command,
  383. }
  384. for _, t := range activeTransfers {
  385. if t.connectionID == c.ID {
  386. if t.lastActivity.UnixNano() > c.lastActivity.UnixNano() {
  387. conn.LastActivity = utils.GetTimeAsMsSinceEpoch(t.lastActivity)
  388. }
  389. var operationType string
  390. var size int64
  391. if t.transferType == transferUpload {
  392. operationType = operationUpload
  393. size = t.bytesReceived
  394. } else {
  395. operationType = operationDownload
  396. size = t.bytesSent
  397. }
  398. connTransfer := connectionTransfer{
  399. OperationType: operationType,
  400. StartTime: utils.GetTimeAsMsSinceEpoch(t.start),
  401. Size: size,
  402. LastActivity: utils.GetTimeAsMsSinceEpoch(t.lastActivity),
  403. Path: c.fs.GetRelativePath(t.path),
  404. }
  405. conn.Transfers = append(conn.Transfers, connTransfer)
  406. }
  407. }
  408. stats = append(stats, conn)
  409. }
  410. return stats
  411. }
  412. func startIdleTimer(maxIdleTime time.Duration) {
  413. idleTimeout = maxIdleTime
  414. go func() {
  415. for range time.Tick(5 * time.Minute) {
  416. CheckIdleConnections()
  417. }
  418. }()
  419. }
  420. // CheckIdleConnections disconnects clients idle for too long, based on IdleTimeout setting
  421. func CheckIdleConnections() {
  422. mutex.RLock()
  423. defer mutex.RUnlock()
  424. for _, c := range openConnections {
  425. idleTime := time.Since(c.lastActivity)
  426. for _, t := range activeTransfers {
  427. if t.connectionID == c.ID {
  428. transferIdleTime := time.Since(t.lastActivity)
  429. if transferIdleTime < idleTime {
  430. c.Log(logger.LevelDebug, logSender, "idle time: %v setted to transfer idle time: %v",
  431. idleTime, transferIdleTime)
  432. idleTime = transferIdleTime
  433. }
  434. }
  435. }
  436. if idleTime > idleTimeout {
  437. err := c.close()
  438. c.Log(logger.LevelInfo, logSender, "close idle connection, idle time: %v, close error: %v", idleTime, err)
  439. }
  440. }
  441. }
  442. func addConnection(c Connection) {
  443. mutex.Lock()
  444. defer mutex.Unlock()
  445. openConnections[c.ID] = c
  446. metrics.UpdateActiveConnectionsSize(len(openConnections))
  447. c.Log(logger.LevelDebug, logSender, "connection added, num open connections: %v", len(openConnections))
  448. }
  449. func removeConnection(c Connection) {
  450. mutex.Lock()
  451. defer mutex.Unlock()
  452. delete(openConnections, c.ID)
  453. metrics.UpdateActiveConnectionsSize(len(openConnections))
  454. // we have finished to send data here and most of the time the underlying network connection
  455. // is already closed. Sometime a client can still be reading the last sended data, so we set
  456. // a deadline instead of directly closing the network connection.
  457. // Setting a deadline on an already closed connection has no effect.
  458. // We only need to ensure that a connection will not remain indefinitely open and so the
  459. // underlying file descriptor is not released.
  460. // This should protect us against buggy clients and edge cases.
  461. c.netConn.SetDeadline(time.Now().Add(2 * time.Minute)) //nolint:errcheck
  462. c.Log(logger.LevelDebug, logSender, "connection removed, num open connections: %v", len(openConnections))
  463. }
  464. func addTransfer(transfer *Transfer) {
  465. mutex.Lock()
  466. defer mutex.Unlock()
  467. activeTransfers = append(activeTransfers, transfer)
  468. }
  469. func removeTransfer(transfer *Transfer) error {
  470. mutex.Lock()
  471. defer mutex.Unlock()
  472. var err error
  473. indexToRemove := -1
  474. for i, v := range activeTransfers {
  475. if v == transfer {
  476. indexToRemove = i
  477. break
  478. }
  479. }
  480. if indexToRemove >= 0 {
  481. activeTransfers[indexToRemove] = activeTransfers[len(activeTransfers)-1]
  482. activeTransfers = activeTransfers[:len(activeTransfers)-1]
  483. } else {
  484. logger.Warn(logSender, transfer.connectionID, "transfer to remove not found!")
  485. err = fmt.Errorf("transfer to remove not found")
  486. }
  487. return err
  488. }
  489. func updateConnectionActivity(id string) {
  490. mutex.Lock()
  491. defer mutex.Unlock()
  492. if c, ok := openConnections[id]; ok {
  493. c.lastActivity = time.Now()
  494. openConnections[id] = c
  495. }
  496. }
  497. func isAtomicUploadEnabled() bool {
  498. return uploadMode == uploadModeAtomic || uploadMode == uploadModeAtomicWithResume
  499. }
  500. func executeNotificationCommand(a actionNotification) error {
  501. if !filepath.IsAbs(actions.Hook) {
  502. err := fmt.Errorf("invalid notification command %#v", actions.Hook)
  503. logger.Warn(logSender, "", "unable to execute notification command: %v", err)
  504. return err
  505. }
  506. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  507. defer cancel()
  508. cmd := exec.CommandContext(ctx, actions.Hook, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd)
  509. cmd.Env = append(os.Environ(), a.AsEnvVars()...)
  510. startTime := time.Now()
  511. err := cmd.Run()
  512. logger.Debug(logSender, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v",
  513. actions.Hook, a.Action, a.Username, a.Path, a.TargetPath, a.SSHCmd, time.Since(startTime), err)
  514. return err
  515. }
  516. func executeAction(a actionNotification) error {
  517. if !utils.IsStringInSlice(a.Action, actions.ExecuteOn) {
  518. return errUnconfiguredAction
  519. }
  520. if len(actions.Hook) == 0 {
  521. logger.Warn(logSender, "", "Unable to send notification, no hook is defined")
  522. return errNoHook
  523. }
  524. if strings.HasPrefix(actions.Hook, "http") {
  525. var url *url.URL
  526. url, err := url.Parse(actions.Hook)
  527. if err != nil {
  528. logger.Warn(logSender, "", "Invalid hook %#v for operation %#v: %v", actions.Hook, a.Action, err)
  529. return err
  530. }
  531. startTime := time.Now()
  532. httpClient := httpclient.GetHTTPClient()
  533. resp, err := httpClient.Post(url.String(), "application/json", bytes.NewBuffer(a.AsJSON()))
  534. respCode := 0
  535. if err == nil {
  536. respCode = resp.StatusCode
  537. resp.Body.Close()
  538. if respCode != http.StatusOK {
  539. err = errUnexpectedHTTResponse
  540. }
  541. }
  542. logger.Debug(logSender, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v",
  543. a.Action, url.String(), respCode, time.Since(startTime), err)
  544. return err
  545. }
  546. return executeNotificationCommand(a)
  547. }