// 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 package sftpd import ( "context" "fmt" "net/http" "net/url" "os" "os/exec" "path/filepath" "sync" "time" "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/metrics" "github.com/drakkan/sftpgo/utils" ) const ( logSender = "sftpd" logSenderSCP = "scp" logSenderSSH = "ssh" uploadLogSender = "Upload" downloadLogSender = "Download" renameLogSender = "Rename" rmdirLogSender = "Rmdir" mkdirLogSender = "Mkdir" symlinkLogSender = "Symlink" removeLogSender = "Remove" chownLogSender = "Chown" chmodLogSender = "Chmod" chtimesLogSender = "Chtimes" sshCommandLogSender = "SSHCommand" operationDownload = "download" operationUpload = "upload" operationDelete = "delete" operationRename = "rename" operationSSHCmd = "ssh_cmd" protocolSFTP = "SFTP" protocolSCP = "SCP" protocolSSH = "SSH" handshakeTimeout = 2 * time.Minute ) const ( uploadModeStandard = iota uploadModeAtomic uploadModeAtomicWithResume ) var ( mutex sync.RWMutex openConnections map[string]Connection activeTransfers []*Transfer idleConnectionTicker *time.Ticker idleTimeout time.Duration activeQuotaScans []ActiveQuotaScan dataProvider dataprovider.Provider actions Actions uploadMode int setstatMode int supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} systemCommands = []string{"git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} ) type connectionTransfer struct { OperationType string `json:"operation_type"` StartTime int64 `json:"start_time"` Size int64 `json:"size"` LastActivity int64 `json:"last_activity"` Path string `json:"path"` } // ActiveQuotaScan defines an active quota scan type ActiveQuotaScan struct { // 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"` } // Actions to execute on SFTP create, download, delete and rename. // An external command can be executed and/or an HTTP notification can be fired type Actions struct { // Valid values are download, upload, delete, rename, ssh_cmd. Empty slice to disable ExecuteOn []string `json:"execute_on" mapstructure:"execute_on"` // Absolute path to the command to execute, empty to disable Command string `json:"command" mapstructure:"command"` // The URL to notify using an HTTP GET, empty to disable HTTPNotificationURL string `json:"http_notification_url" mapstructure:"http_notification_url"` } // ConnectionStatus status for an active connection type ConnectionStatus struct { // 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"` // Protocol for this connection: SFTP, SCP, SSH Protocol string `json:"protocol"` // active uploads/downloads Transfers []connectionTransfer `json:"active_transfers"` // for protocol SSH this is the issued command SSHCommand string `json:"ssh_command"` } type sshSubsystemExitStatus struct { Status uint32 } type sshSubsystemExecMsg struct { Command string } func init() { openConnections = make(map[string]Connection) idleConnectionTicker = time.NewTicker(5 * time.Minute) } // GetDefaultSSHCommands returns the SSH commands enabled as default func GetDefaultSSHCommands() []string { result := make([]string, len(defaultSSHCommands)) copy(result, defaultSSHCommands) return result } // GetSupportedSSHCommands returns the supported SSH commands func GetSupportedSSHCommands() []string { result := make([]string, len(supportedSSHCommands)) copy(result, supportedSSHCommands) return result } // 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. // For SSH commands the issued command is returned too. func (c ConnectionStatus) GetConnectionInfo() string { result := fmt.Sprintf("%v. Client: %#v From: %#v", c.Protocol, c.ClientVersion, c.RemoteAddress) if c.Protocol == protocolSSH && len(c.SSHCommand) > 0 { result += fmt.Sprintf(". Command: %#v", c.SSHCommand) } return result } // 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 } // SetDataProvider sets the data provider to use to authenticate users and to get/update their disk quota 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 } // RemoveQuotaScan removes an user from the ones with active quota scans func RemoveQuotaScan(username string) error { mutex.Lock() defer mutex.Unlock() var err error 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] } else { logger.Warn(logSender, "", "quota scan to remove not found for user: %v", username) err = fmt.Errorf("quota scan to remove not found for user: %v", username) } return err } // CloseActiveConnection closes an active SFTP connection. // It returns true on success func CloseActiveConnection(connectionID string) bool { result := false mutex.RLock() defer mutex.RUnlock() if c, ok := openConnections[connectionID]; ok { err := c.close() c.Log(logger.LevelDebug, logSender, "close connection requested, close err: %v", err) result = true } 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), Protocol: c.protocol, Transfers: []connectionTransfer{}, SSHCommand: c.command, } for _, t := range activeTransfers { if t.connectionID == c.ID { if t.lastActivity.UnixNano() > c.lastActivity.UnixNano() { 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), Path: c.fs.GetRelativePath(t.path), } 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 { logger.Debug(logSender, "", "idle connections check ticker %v", t) CheckIdleConnections() } }() } // CheckIdleConnections disconnects clients idle for too long, based on IdleTimeout setting func CheckIdleConnections() { 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 { c.Log(logger.LevelDebug, logSender, "idle time: %v setted to transfer idle time: %v", idleTime, transferIdleTime) idleTime = transferIdleTime } } } if idleTime > idleTimeout { err := c.close() c.Log(logger.LevelInfo, logSender, "close idle connection, idle time: %v, close error: %v", idleTime, err) } } logger.Debug(logSender, "", "check idle connections ended") } func addConnection(c Connection) { mutex.Lock() defer mutex.Unlock() openConnections[c.ID] = c metrics.UpdateActiveConnectionsSize(len(openConnections)) c.Log(logger.LevelDebug, logSender, "connection added, num open connections: %v", len(openConnections)) } func removeConnection(c Connection) { mutex.Lock() defer mutex.Unlock() delete(openConnections, c.ID) metrics.UpdateActiveConnectionsSize(len(openConnections)) // we have finished to send data here and most of the time the underlying network connection // is already closed. Sometime a client can still be reading the last sended data, so we set // a deadline instead of directly closing the network connection. // Setting a deadline on an already closed connection has no effect. // We only need to ensure that a connection will not remain indefinitely open and so the // underlying file descriptor is not released. // This should protect us against buggy clients and edge cases. c.netConn.SetDeadline(time.Now().Add(2 * time.Minute)) c.Log(logger.LevelDebug, logSender, "connection removed, num open connections: %v", len(openConnections)) } func addTransfer(transfer *Transfer) { mutex.Lock() defer mutex.Unlock() activeTransfers = append(activeTransfers, transfer) } func removeTransfer(transfer *Transfer) error { mutex.Lock() defer mutex.Unlock() var err error 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 { logger.Warn(logSender, transfer.connectionID, "transfer to remove not found!") err = fmt.Errorf("transfer to remove not found") } return err } func updateConnectionActivity(id string) { mutex.Lock() defer mutex.Unlock() if c, ok := openConnections[id]; ok { c.lastActivity = time.Now() openConnections[id] = c } } func isAtomicUploadEnabled() bool { return uploadMode == uploadModeAtomic || uploadMode == uploadModeAtomicWithResume } func executeNotificationCommand(operation, username, path, target, sshCmd, fileSize, isLocalFile string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, actions.Command, operation, username, path, target, sshCmd) cmd.Env = append(os.Environ(), fmt.Sprintf("SFTPGO_ACTION=%v", operation), fmt.Sprintf("SFTPGO_ACTION_USERNAME=%v", username), fmt.Sprintf("SFTPGO_ACTION_PATH=%v", path), fmt.Sprintf("SFTPGO_ACTION_TARGET=%v", target), fmt.Sprintf("SFTPGO_ACTION_SSH_CMD=%v", sshCmd), fmt.Sprintf("SFTPGO_ACTION_FILE_SIZE=%v", fileSize), fmt.Sprintf("SFTPGO_ACTION_LOCAL_FILE=%v", isLocalFile), ) startTime := time.Now() err := cmd.Run() logger.Debug(logSender, "", "executed command %#v with arguments: %#v, %#v, %#v, %#v, %#v, elapsed: %v, error: %v", actions.Command, operation, username, path, target, sshCmd, time.Since(startTime), err) return err } // executed in a goroutine func executeAction(operation, username, path, target, sshCmd string, fileSize int64, isLocalFile bool) error { if !utils.IsStringInSlice(operation, actions.ExecuteOn) { return nil } var err error size := "" if fileSize > 0 { size = fmt.Sprintf("%v", fileSize) } if len(actions.Command) > 0 && filepath.IsAbs(actions.Command) { // we are in a goroutine but if we have to send an HTTP notification we don't want to wait for the // end of the command if len(actions.HTTPNotificationURL) > 0 { go executeNotificationCommand(operation, username, path, target, sshCmd, size, fmt.Sprintf("%t", isLocalFile)) } else { err = executeNotificationCommand(operation, username, path, target, sshCmd, size, fmt.Sprintf("%t", isLocalFile)) } } if len(actions.HTTPNotificationURL) > 0 { var url *url.URL url, err = url.Parse(actions.HTTPNotificationURL) if err == nil { q := url.Query() q.Add("action", operation) q.Add("username", username) q.Add("path", path) if len(target) > 0 { q.Add("target_path", target) } if len(sshCmd) > 0 { q.Add("ssh_cmd", sshCmd) } if len(size) > 0 { q.Add("file_size", size) } q.Add("local_file", fmt.Sprintf("%t", isLocalFile)) url.RawQuery = q.Encode() 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() } logger.Debug(logSender, "", "notified operation %#v to URL: %v status code: %v, elapsed: %v err: %v", operation, url.String(), respCode, time.Since(startTime), err) } else { logger.Warn(logSender, "", "Invalid http_notification_url %#v for operation %#v: %v", actions.HTTPNotificationURL, operation, err) } } return err }