sftpd.go 18 KB

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