2019-07-30 18:51:29 +00:00
|
|
|
// Package sftpd implements the SSH File Transfer Protocol as described in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02.
|
|
|
|
// It uses pkg/sftp library:
|
|
|
|
// https://github.com/pkg/sftp
|
2019-07-20 10:26:52 +00:00
|
|
|
package sftpd
|
|
|
|
|
|
|
|
import (
|
2019-07-27 07:38:09 +00:00
|
|
|
"fmt"
|
|
|
|
"net/http"
|
2019-07-27 17:29:33 +00:00
|
|
|
"net/url"
|
2019-07-27 07:38:09 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
|
|
|
"path/filepath"
|
2019-07-20 10:26:52 +00:00
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/drakkan/sftpgo/dataprovider"
|
|
|
|
"github.com/drakkan/sftpgo/logger"
|
2019-09-13 16:45:36 +00:00
|
|
|
"github.com/drakkan/sftpgo/metrics"
|
2019-07-20 10:26:52 +00:00
|
|
|
"github.com/drakkan/sftpgo/utils"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2019-08-24 12:41:15 +00:00
|
|
|
logSender = "sftpd"
|
|
|
|
logSenderSCP = "scp"
|
|
|
|
uploadLogSender = "Upload"
|
|
|
|
downloadLogSender = "Download"
|
|
|
|
renameLogSender = "Rename"
|
|
|
|
rmdirLogSender = "Rmdir"
|
|
|
|
mkdirLogSender = "Mkdir"
|
|
|
|
symlinkLogSender = "Symlink"
|
|
|
|
removeLogSender = "Remove"
|
|
|
|
operationDownload = "download"
|
|
|
|
operationUpload = "upload"
|
|
|
|
operationDelete = "delete"
|
|
|
|
operationRename = "rename"
|
|
|
|
protocolSFTP = "SFTP"
|
|
|
|
protocolSCP = "SCP"
|
2019-07-20 10:26:52 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
mutex sync.RWMutex
|
|
|
|
openConnections map[string]Connection
|
|
|
|
activeTransfers []*Transfer
|
|
|
|
idleConnectionTicker *time.Ticker
|
|
|
|
idleTimeout time.Duration
|
|
|
|
activeQuotaScans []ActiveQuotaScan
|
|
|
|
dataProvider dataprovider.Provider
|
2019-07-27 07:38:09 +00:00
|
|
|
actions Actions
|
2019-08-04 07:37:58 +00:00
|
|
|
uploadMode int
|
2019-07-20 10:26:52 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type connectionTransfer struct {
|
|
|
|
OperationType string `json:"operation_type"`
|
|
|
|
StartTime int64 `json:"start_time"`
|
|
|
|
Size int64 `json:"size"`
|
|
|
|
LastActivity int64 `json:"last_activity"`
|
2019-08-08 17:33:16 +00:00
|
|
|
Path string `json:"path"`
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// ActiveQuotaScan defines an active quota scan
|
2019-07-20 10:26:52 +00:00
|
|
|
type ActiveQuotaScan struct {
|
2019-07-30 18:51:29 +00:00
|
|
|
// Username to which the quota scan refers
|
|
|
|
Username string `json:"username"`
|
|
|
|
// quota scan start time as unix timestamp in milliseconds
|
|
|
|
StartTime int64 `json:"start_time"`
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// Actions to execute on SFTP create, download, delete and rename.
|
|
|
|
// An external command can be executed and/or an HTTP notification can be fired
|
2019-07-27 07:38:09 +00:00
|
|
|
type Actions struct {
|
2019-07-30 18:51:29 +00:00
|
|
|
// Valid values are download, upload, delete, rename. Empty slice to disable
|
2019-08-07 20:46:13 +00:00
|
|
|
ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"`
|
2019-07-30 18:51:29 +00:00
|
|
|
// Absolute path to the command to execute, empty to disable
|
2019-08-07 20:46:13 +00:00
|
|
|
Command string `json:"command" mapstructure:"command"`
|
2019-07-30 18:51:29 +00:00
|
|
|
// The URL to notify using an HTTP GET, empty to disable
|
2019-08-07 20:46:13 +00:00
|
|
|
HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"`
|
2019-07-27 07:38:09 +00:00
|
|
|
}
|
|
|
|
|
2019-07-20 10:26:52 +00:00
|
|
|
// ConnectionStatus status for an active connection
|
|
|
|
type ConnectionStatus struct {
|
2019-07-30 18:51:29 +00:00
|
|
|
// Logged in username
|
|
|
|
Username string `json:"username"`
|
|
|
|
// Unique identifier for the connection
|
|
|
|
ConnectionID string `json:"connection_id"`
|
|
|
|
// client's version string
|
|
|
|
ClientVersion string `json:"client_version"`
|
|
|
|
// Remote address for this connection
|
|
|
|
RemoteAddress string `json:"remote_address"`
|
|
|
|
// Connection time as unix timestamp in milliseconds
|
|
|
|
ConnectionTime int64 `json:"connection_time"`
|
|
|
|
// Last activity as unix timestamp in milliseconds
|
|
|
|
LastActivity int64 `json:"last_activity"`
|
2019-08-24 12:41:15 +00:00
|
|
|
// Protocol for this connection: SFTP or SCP
|
|
|
|
Protocol string `json:"protocol"`
|
2019-07-30 18:51:29 +00:00
|
|
|
// active uploads/downloads
|
|
|
|
Transfers []connectionTransfer `json:"active_transfers"`
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
openConnections = make(map[string]Connection)
|
2019-07-27 07:38:09 +00:00
|
|
|
idleConnectionTicker = time.NewTicker(5 * time.Minute)
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
2019-10-07 16:19:01 +00:00
|
|
|
// GetConnectionDuration returns the connection duration as string
|
|
|
|
func (c ConnectionStatus) GetConnectionDuration() string {
|
|
|
|
elapsed := time.Since(utils.GetTimeFromMsecSinceEpoch(c.ConnectionTime))
|
|
|
|
return utils.GetDurationAsString(elapsed)
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetConnectionInfo returns connection info.
|
|
|
|
// Protocol,Client Version and RemoteAddress are returned
|
|
|
|
func (c ConnectionStatus) GetConnectionInfo() string {
|
|
|
|
return fmt.Sprintf("%v. Client: %#v From: %#v", c.Protocol, c.ClientVersion, c.RemoteAddress)
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetTransfersAsString returns the active transfers as string
|
|
|
|
func (c ConnectionStatus) GetTransfersAsString() string {
|
|
|
|
result := ""
|
|
|
|
for _, t := range c.Transfers {
|
|
|
|
if len(result) > 0 {
|
|
|
|
result += ". "
|
|
|
|
}
|
|
|
|
result += t.getConnectionTransferAsString()
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
func (t connectionTransfer) getConnectionTransferAsString() string {
|
|
|
|
result := ""
|
|
|
|
if t.OperationType == operationUpload {
|
|
|
|
result += "UL"
|
|
|
|
} else {
|
|
|
|
result += "DL"
|
|
|
|
}
|
|
|
|
result += fmt.Sprintf(" %#v ", t.Path)
|
|
|
|
if t.Size > 0 {
|
|
|
|
elapsed := time.Since(utils.GetTimeFromMsecSinceEpoch(t.StartTime))
|
|
|
|
speed := float64(t.Size) / float64(utils.GetTimeAsMsSinceEpoch(time.Now())-t.StartTime)
|
|
|
|
result += fmt.Sprintf("Size: %#v Elapsed: %#v Speed: \"%.1f KB/s\"", utils.ByteCountSI(t.Size),
|
|
|
|
utils.GetDurationAsString(elapsed), speed)
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// SetDataProvider sets the data provider to use to authenticate users and to get/update their disk quota
|
2019-07-20 10:26:52 +00:00
|
|
|
func SetDataProvider(provider dataprovider.Provider) {
|
|
|
|
dataProvider = provider
|
|
|
|
}
|
|
|
|
|
|
|
|
func getActiveSessions(username string) int {
|
|
|
|
mutex.RLock()
|
|
|
|
defer mutex.RUnlock()
|
|
|
|
numSessions := 0
|
|
|
|
for _, c := range openConnections {
|
|
|
|
if c.User.Username == username {
|
|
|
|
numSessions++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return numSessions
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetQuotaScans returns the active quota scans
|
|
|
|
func GetQuotaScans() []ActiveQuotaScan {
|
|
|
|
mutex.RLock()
|
|
|
|
defer mutex.RUnlock()
|
|
|
|
scans := make([]ActiveQuotaScan, len(activeQuotaScans))
|
|
|
|
copy(scans, activeQuotaScans)
|
|
|
|
return scans
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddQuotaScan add an user to the ones with active quota scans.
|
|
|
|
// Returns false if the user has a quota scan already running
|
|
|
|
func AddQuotaScan(username string) bool {
|
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
|
|
|
for _, s := range activeQuotaScans {
|
|
|
|
if s.Username == username {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
activeQuotaScans = append(activeQuotaScans, ActiveQuotaScan{
|
|
|
|
Username: username,
|
|
|
|
StartTime: utils.GetTimeAsMsSinceEpoch(time.Now()),
|
|
|
|
})
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// RemoveQuotaScan removes an user from the ones with active quota scans
|
2019-07-27 07:38:09 +00:00
|
|
|
func RemoveQuotaScan(username string) error {
|
2019-07-20 10:26:52 +00:00
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
2019-07-27 07:38:09 +00:00
|
|
|
var err error
|
2019-07-20 10:26:52 +00:00
|
|
|
indexToRemove := -1
|
|
|
|
for i, s := range activeQuotaScans {
|
|
|
|
if s.Username == username {
|
|
|
|
indexToRemove = i
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if indexToRemove >= 0 {
|
|
|
|
activeQuotaScans[indexToRemove] = activeQuotaScans[len(activeQuotaScans)-1]
|
|
|
|
activeQuotaScans = activeQuotaScans[:len(activeQuotaScans)-1]
|
2019-07-27 07:38:09 +00:00
|
|
|
} else {
|
2019-09-05 14:21:35 +00:00
|
|
|
logger.Warn(logSender, "", "quota scan to remove not found for user: %v", username)
|
2019-07-27 07:38:09 +00:00
|
|
|
err = fmt.Errorf("quota scan to remove not found for user: %v", username)
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
2019-07-27 07:38:09 +00:00
|
|
|
return err
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// CloseActiveConnection closes an active SFTP connection.
|
|
|
|
// It returns true on success
|
2019-07-20 10:26:52 +00:00
|
|
|
func CloseActiveConnection(connectionID string) bool {
|
|
|
|
result := false
|
|
|
|
mutex.RLock()
|
|
|
|
defer mutex.RUnlock()
|
|
|
|
for _, c := range openConnections {
|
|
|
|
if c.ID == connectionID {
|
2019-09-11 10:46:21 +00:00
|
|
|
err := c.close()
|
|
|
|
c.Log(logger.LevelDebug, logSender, "close connection requested, close err: %v", err)
|
2019-07-20 10:26:52 +00:00
|
|
|
result = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetConnectionsStats returns stats for active connections
|
|
|
|
func GetConnectionsStats() []ConnectionStatus {
|
|
|
|
mutex.RLock()
|
|
|
|
defer mutex.RUnlock()
|
|
|
|
stats := []ConnectionStatus{}
|
|
|
|
for _, c := range openConnections {
|
|
|
|
conn := ConnectionStatus{
|
|
|
|
Username: c.User.Username,
|
|
|
|
ConnectionID: c.ID,
|
|
|
|
ClientVersion: c.ClientVersion,
|
|
|
|
RemoteAddress: c.RemoteAddr.String(),
|
|
|
|
ConnectionTime: utils.GetTimeAsMsSinceEpoch(c.StartTime),
|
|
|
|
LastActivity: utils.GetTimeAsMsSinceEpoch(c.lastActivity),
|
2019-08-24 12:41:15 +00:00
|
|
|
Protocol: c.protocol,
|
2019-07-20 10:26:52 +00:00
|
|
|
Transfers: []connectionTransfer{},
|
|
|
|
}
|
|
|
|
for _, t := range activeTransfers {
|
|
|
|
if t.connectionID == c.ID {
|
2019-08-08 08:01:33 +00:00
|
|
|
if t.lastActivity.UnixNano() > c.lastActivity.UnixNano() {
|
2019-07-20 10:26:52 +00:00
|
|
|
conn.LastActivity = utils.GetTimeAsMsSinceEpoch(t.lastActivity)
|
|
|
|
}
|
|
|
|
var operationType string
|
|
|
|
var size int64
|
|
|
|
if t.transferType == transferUpload {
|
|
|
|
operationType = operationUpload
|
|
|
|
size = t.bytesReceived
|
|
|
|
} else {
|
|
|
|
operationType = operationDownload
|
|
|
|
size = t.bytesSent
|
|
|
|
}
|
|
|
|
connTransfer := connectionTransfer{
|
|
|
|
OperationType: operationType,
|
|
|
|
StartTime: utils.GetTimeAsMsSinceEpoch(t.start),
|
|
|
|
Size: size,
|
|
|
|
LastActivity: utils.GetTimeAsMsSinceEpoch(t.lastActivity),
|
2019-08-08 17:33:16 +00:00
|
|
|
Path: c.User.GetRelativePath(t.path),
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
conn.Transfers = append(conn.Transfers, connTransfer)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
stats = append(stats, conn)
|
|
|
|
}
|
|
|
|
return stats
|
|
|
|
}
|
|
|
|
|
|
|
|
func startIdleTimer(maxIdleTime time.Duration) {
|
|
|
|
idleTimeout = maxIdleTime
|
|
|
|
go func() {
|
|
|
|
for t := range idleConnectionTicker.C {
|
2019-09-05 14:21:35 +00:00
|
|
|
logger.Debug(logSender, "", "idle connections check ticker %v", t)
|
2019-07-26 13:08:08 +00:00
|
|
|
CheckIdleConnections()
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
2019-07-30 18:51:29 +00:00
|
|
|
// CheckIdleConnections disconnects clients idle for too long, based on IdleTimeout setting
|
2019-07-26 13:08:08 +00:00
|
|
|
func CheckIdleConnections() {
|
2019-07-20 10:26:52 +00:00
|
|
|
mutex.RLock()
|
|
|
|
defer mutex.RUnlock()
|
|
|
|
for _, c := range openConnections {
|
|
|
|
idleTime := time.Since(c.lastActivity)
|
|
|
|
for _, t := range activeTransfers {
|
|
|
|
if t.connectionID == c.ID {
|
|
|
|
transferIdleTime := time.Since(t.lastActivity)
|
|
|
|
if transferIdleTime < idleTime {
|
2019-09-06 13:19:01 +00:00
|
|
|
c.Log(logger.LevelDebug, logSender, "idle time: %v setted to transfer idle time: %v",
|
2019-09-05 21:42:00 +00:00
|
|
|
idleTime, transferIdleTime)
|
2019-07-20 10:26:52 +00:00
|
|
|
idleTime = transferIdleTime
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if idleTime > idleTimeout {
|
2019-09-11 10:46:21 +00:00
|
|
|
err := c.close()
|
|
|
|
c.Log(logger.LevelInfo, logSender, "close idle connection, idle time: %v, close error: %v", idleTime, err)
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
}
|
2019-09-05 14:21:35 +00:00
|
|
|
logger.Debug(logSender, "", "check idle connections ended")
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
2019-09-06 09:23:06 +00:00
|
|
|
func addConnection(id string, c Connection) {
|
2019-07-20 10:26:52 +00:00
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
2019-09-06 09:23:06 +00:00
|
|
|
openConnections[id] = c
|
2019-09-13 16:45:36 +00:00
|
|
|
metrics.UpdateActiveConnectionsSize(len(openConnections))
|
2019-09-06 13:19:01 +00:00
|
|
|
c.Log(logger.LevelDebug, logSender, "connection added, num open connections: %v", len(openConnections))
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func removeConnection(id string) {
|
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
2019-09-06 09:23:06 +00:00
|
|
|
c := openConnections[id]
|
2019-07-20 10:26:52 +00:00
|
|
|
delete(openConnections, id)
|
2019-09-13 16:45:36 +00:00
|
|
|
metrics.UpdateActiveConnectionsSize(len(openConnections))
|
2019-09-06 13:19:01 +00:00
|
|
|
c.Log(logger.LevelDebug, logSender, "connection removed, num open connections: %v", len(openConnections))
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func addTransfer(transfer *Transfer) {
|
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
|
|
|
activeTransfers = append(activeTransfers, transfer)
|
|
|
|
}
|
|
|
|
|
2019-07-27 07:38:09 +00:00
|
|
|
func removeTransfer(transfer *Transfer) error {
|
2019-07-20 10:26:52 +00:00
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
2019-07-27 07:38:09 +00:00
|
|
|
var err error
|
2019-07-20 10:26:52 +00:00
|
|
|
indexToRemove := -1
|
|
|
|
for i, v := range activeTransfers {
|
|
|
|
if v == transfer {
|
|
|
|
indexToRemove = i
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if indexToRemove >= 0 {
|
|
|
|
activeTransfers[indexToRemove] = activeTransfers[len(activeTransfers)-1]
|
|
|
|
activeTransfers = activeTransfers[:len(activeTransfers)-1]
|
|
|
|
} else {
|
2019-09-05 21:42:00 +00:00
|
|
|
logger.Warn(logSender, transfer.connectionID, "transfer to remove not found!")
|
2019-07-27 07:38:09 +00:00
|
|
|
err = fmt.Errorf("transfer to remove not found")
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
2019-07-27 07:38:09 +00:00
|
|
|
return err
|
2019-07-20 10:26:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func updateConnectionActivity(id string) {
|
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
|
|
|
if c, ok := openConnections[id]; ok {
|
|
|
|
c.lastActivity = time.Now()
|
|
|
|
openConnections[id] = c
|
|
|
|
}
|
|
|
|
}
|
2019-07-27 07:38:09 +00:00
|
|
|
|
|
|
|
func executeAction(operation string, username string, path string, target string) error {
|
|
|
|
if !utils.IsStringInSlice(operation, actions.ExecuteOn) {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
var err error
|
|
|
|
if len(actions.Command) > 0 && filepath.IsAbs(actions.Command) {
|
|
|
|
if _, err = os.Stat(actions.Command); err == nil {
|
|
|
|
command := exec.Command(actions.Command, operation, username, path, target)
|
|
|
|
err = command.Start()
|
2019-09-05 21:42:00 +00:00
|
|
|
logger.Debug(logSender, "", "start command %#v with arguments: %v, %v, %v, %v, error: %v",
|
2019-07-28 11:24:46 +00:00
|
|
|
actions.Command, operation, username, path, target, err)
|
2019-08-30 01:58:54 +00:00
|
|
|
if err == nil {
|
|
|
|
go command.Wait()
|
|
|
|
}
|
2019-07-27 07:38:09 +00:00
|
|
|
} else {
|
2019-09-05 21:42:00 +00:00
|
|
|
logger.Warn(logSender, "", "Invalid action command %#v : %v", actions.Command, err)
|
2019-07-27 07:38:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(actions.HTTPNotificationURL) > 0 {
|
2019-07-27 17:29:33 +00:00
|
|
|
var url *url.URL
|
|
|
|
url, err = url.Parse(actions.HTTPNotificationURL)
|
2019-07-27 07:38:09 +00:00
|
|
|
if err == nil {
|
2019-07-27 17:29:33 +00:00
|
|
|
q := url.Query()
|
2019-07-27 07:38:09 +00:00
|
|
|
q.Add("action", operation)
|
|
|
|
q.Add("username", username)
|
|
|
|
q.Add("path", path)
|
|
|
|
if len(target) > 0 {
|
|
|
|
q.Add("target_path", target)
|
|
|
|
}
|
2019-07-27 17:29:33 +00:00
|
|
|
url.RawQuery = q.Encode()
|
|
|
|
go func() {
|
|
|
|
startTime := time.Now()
|
|
|
|
httpClient := &http.Client{
|
|
|
|
Timeout: 15 * time.Second,
|
|
|
|
}
|
|
|
|
resp, err := httpClient.Get(url.String())
|
|
|
|
respCode := 0
|
|
|
|
if err == nil {
|
|
|
|
respCode = resp.StatusCode
|
|
|
|
resp.Body.Close()
|
|
|
|
}
|
2019-09-05 14:21:35 +00:00
|
|
|
logger.Debug(logSender, "", "notified action to URL: %v status code: %v, elapsed: %v err: %v",
|
2019-07-27 17:29:33 +00:00
|
|
|
url.String(), respCode, time.Since(startTime), err)
|
|
|
|
}()
|
2019-07-27 07:38:09 +00:00
|
|
|
} else {
|
2019-09-05 21:42:00 +00:00
|
|
|
logger.Warn(logSender, "", "Invalid http_notification_url %#v : %v", actions.HTTPNotificationURL, err)
|
2019-07-27 07:38:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|