sftpd: add support for some SSH commands

md5sum, sha1sum are used by rclone.
cd, pwd improve the support for RemoteFiles mobile app.

These commands are all implemented inside SFTPGo so they work even
if the matching system commands are not available, for example on Windows
This commit is contained in:
Nicola Murino 2019-11-18 23:30:37 +01:00
parent ca6cb34d98
commit 9c4dbbc3f8
14 changed files with 564 additions and 131 deletions

View file

@ -145,12 +145,16 @@ The `sftpgo` configuration file contains the following sections:
- `target_path`, added for `rename` action only
- `keys`, struct array. It contains the daemon's private keys. If empty or missing the daemon will search or try to generate `id_rsa` in the configuration directory.
- `private_key`, path to the private key file. It can be a path relative to the config dir or an absolute one.
- `enable_scp`, boolean. Default disabled. Set to `true` to enable SCP support. SCP is an experimental feature, we have our own SCP implementation since we can't rely on `scp` system command to proper handle permissions, quota and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedbacks before enabling it by default. We may not handle some borderline cases or have sneaky bugs. Please do accurate tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. SCP between two remote hosts is supported using the `-3` scp option.
- `enable_scp`, boolean. Default disabled. Set to `true` to enable the experimental SCP support. This setting is deprecated and will be removed in future versions, please add `scp` to the `enabled_ssh_commands` list to enable it
- `kex_algorithms`, list of strings. Available KEX (Key Exchange) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L46 "Supported kex algos")
- `ciphers`, list of strings. Allowed ciphers. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L28 "Supported ciphers")
- `macs`, list of strings. available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values can be found here: [`crypto/ssh`](https://github.com/golang/crypto/blob/master/ssh/common.go#L84 "Supported MACs")
- `login_banner_file`, path to the login banner file. The contents of the specified file, if any, are sent to the remote user before authentication is allowed. It can be a path relative to the config dir or an absolute one. Leave empty to send no login banner
- `setstat_mode`, integer. 0 means "normal mode": requests for changing permissions, owner/group and access/modification times are executed. 1 means "ignore mode": requests for changing permissions, owner/group and access/modification times are silently ignored.
- `enabled_ssh_commands`, list of enabled SSH commands. These SSH commands are enabled by default: `md5sum`, `sha1sum`, `cd`, `pwd`. `*` enables all supported commands. We support the following SSH commands:
- `scp`, SCP is an experimental feature, we have our own SCP implementation since we can't rely on scp system command to proper handle permissions, quota and user's home dir restrictions. The SCP protocol is quite simple but there is no official docs about it, so we need more testing and feedbacks before enabling it by default. We may not handle some borderline cases or have sneaky bugs. Please do accurate tests yourself before enabling SCP and let us known if something does not work as expected for your use cases. SCP between two remote hosts is supported using the `-3` scp option.
- `md5sum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. Useful to check message digests for uploaded files. These commands are implemented inside SFTPGo so they work even if the matching system commands are not available, for example on Windows.
- `cd`, `pwd`. Some SFTP clients does not support the SFTP SSH_FXP_REALPATH and so they use `cd` and `pwd` SSH commands to get the initial directory. Currently `cd` do nothing and `pwd` always returns the `/` path.
- **"data_provider"**, the configuration for the data provider
- `driver`, string. Supported drivers are `sqlite`, `mysql`, `postgresql`, `bolt`, `memory`
- `name`, string. Database name. For driver `sqlite` this can be the database name relative to the config dir or the absolute path to the SQLite database.
@ -209,7 +213,8 @@ Here is a full example showing the default config in JSON format:
"ciphers": [],
"macs": [],
"login_banner_file": "",
"setstat_mode": 0
"setstat_mode": 0,
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"]
},
"data_provider": {
"driver": "sqlite",
@ -324,8 +329,8 @@ Flags:
-p, --password string Leave empty to use an auto generated value
-g, --permissions strings User's permissions. "*" means any permission (default [list,download])
-k, --public-key strings
--scp Enable SCP
-s, --sftpd-port int 0 means a random non privileged port
-c, --ssh-commands strings SSH commands to enable. "*" means any supported SSH command including scp (default [md5sum,sha1sum,cd,pwd])
-u, --username string Leave empty to use an auto generated value
```

View file

@ -5,13 +5,13 @@ import (
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/service"
"github.com/drakkan/sftpgo/sftpd"
"github.com/spf13/cobra"
)
var (
directoryToServe string
portableSFTPDPort int
portableEnableSCP bool
portableAdvertiseService bool
portableAdvertiseCredentials bool
portableUsername string
@ -19,6 +19,7 @@ var (
portableLogFile string
portablePublicKeys []string
portablePermissions []string
portableSSHCommands []string
portableCmd = &cobra.Command{
Use: "portable",
Short: "Serve a single directory",
@ -52,7 +53,7 @@ Please take a look at the usage below to customize the serving parameters`,
Status: 1,
},
}
if err := service.StartPortableMode(portableSFTPDPort, portableEnableSCP, portableAdvertiseService,
if err := service.StartPortableMode(portableSFTPDPort, portableSSHCommands, portableAdvertiseService,
portableAdvertiseCredentials); err == nil {
service.Wait()
}
@ -64,7 +65,8 @@ func init() {
portableCmd.Flags().StringVarP(&directoryToServe, "directory", "d", ".",
"Path to the directory to serve. This can be an absolute path or a path relative to the current directory")
portableCmd.Flags().IntVarP(&portableSFTPDPort, "sftpd-port", "s", 0, "0 means a random non privileged port")
portableCmd.Flags().BoolVar(&portableEnableSCP, "scp", false, "Enable SCP")
portableCmd.Flags().StringSliceVarP(&portableSSHCommands, "ssh-commands", "c", sftpd.GetDefaultSSHCommands(),
"SSH commands to enable. \"*\" means any supported SSH command including scp")
portableCmd.Flags().StringVarP(&portableUsername, "username", "u", "", "Leave empty to use an auto generated value")
portableCmd.Flags().StringVarP(&portablePassword, "password", "p", "", "Leave empty to use an auto generated value")
portableCmd.Flags().StringVarP(&portableLogFile, logFilePathFlag, "l", "", "Leave empty to disable logging")

View file

@ -54,12 +54,13 @@ func init() {
Command: "",
HTTPNotificationURL: "",
},
Keys: []sftpd.Key{},
IsSCPEnabled: false,
KexAlgorithms: []string{},
Ciphers: []string{},
MACs: []string{},
LoginBannerFile: "",
Keys: []sftpd.Key{},
IsSCPEnabled: false,
KexAlgorithms: []string{},
Ciphers: []string{},
MACs: []string{},
LoginBannerFile: "",
EnabledSSHCommands: sftpd.GetDefaultSSHCommands(),
},
ProviderConf: dataprovider.Config{
Driver: "sqlite",

View file

@ -288,7 +288,7 @@ paths:
post:
tags:
- users
summary: Adds a new SFTP/SCP user
summary: Adds a new user
operationId: add_user
requestBody:
required: true
@ -656,7 +656,7 @@ components:
- download
path:
type: string
description: SFTP/SCP file path for the upload/download
description: file path for the upload/download
start_time:
type: integer
format: int64
@ -680,14 +680,17 @@ components:
description: unique connection identifier
client_version:
type: string
description: SFTP/SCP client version
description: client version
remote_address:
type: string
description: Remote address for the connected SFTP/SCP client
description: Remote address for the connected client
connection_time:
type: integer
format: int64
description: connection time as unix timestamp in milliseconds
ssh_command:
type: string
description: SSH command. This is not empty for protocol SSH
last_activity:
type: integer
format: int64
@ -697,6 +700,7 @@ components:
enum:
- SFTP
- SCP
- SSH
active_transfers:
type: array
items:

View file

@ -184,6 +184,7 @@ Output:
"connection_time": 1564696137971,
"last_activity": 1564696159605,
"protocol": "SFTP",
"ssh_command": "",
"active_transfers": [
{
"operation_type": "upload",

View file

@ -128,7 +128,7 @@ func (s *Service) Stop() {
}
// StartPortableMode starts the service in portable mode
func (s *Service) StartPortableMode(sftpdPort int, enableSCP, advertiseService, advertiseCredentials bool) error {
func (s *Service) StartPortableMode(sftpdPort int, enabledSSHCommands []string, advertiseService, advertiseCredentials bool) error {
if s.PortableMode != 1 {
return fmt.Errorf("service is not configured for portable mode")
}
@ -158,7 +158,11 @@ func (s *Service) StartPortableMode(sftpdPort int, enableSCP, advertiseService,
// dynamic ports starts from 49152
sftpdConf.BindPort = 49152 + rand.Intn(15000)
}
sftpdConf.IsSCPEnabled = enableSCP
if utils.IsStringInSlice("*", enabledSSHCommands) {
sftpdConf.EnabledSSHCommands = sftpd.GetSupportedSSHCommands()
} else {
sftpdConf.EnabledSSHCommands = enabledSSHCommands
}
config.SetSFTPDConfig(sftpdConf)
err = s.Start()
@ -206,8 +210,8 @@ func (s *Service) StartPortableMode(sftpdPort int, enableSCP, advertiseService,
s.Stop()
}()
logger.InfoToConsole("Portable mode ready, SFTP port: %v, user: %#v, password: %#v, public keys: %v, directory: %#v, "+
"permissions: %v, SCP enabled: %v", sftpdConf.BindPort, s.PortableUser.Username, s.PortableUser.Password,
s.PortableUser.PublicKeys, s.PortableUser.HomeDir, s.PortableUser.Permissions, sftpdConf.IsSCPEnabled)
"permissions: %v, enabled ssh commands: %v", sftpdConf.BindPort, s.PortableUser.Username, s.PortableUser.Password,
s.PortableUser.PublicKeys, s.PortableUser.HomeDir, s.PortableUser.Permissions, sftpdConf.EnabledSSHCommands)
}
return err
}

View file

@ -39,6 +39,7 @@ type Connection struct {
lock *sync.Mutex
netConn net.Conn
channel ssh.Channel
command string
}
// Log outputs a log entry to the configured logger
@ -511,8 +512,8 @@ func (c Connection) hasSpace(checkFiles bool) bool {
return true
}
// Normalizes a directory we get from the SFTP request to ensure the user is not able to escape
// from their data directory. After normalization if the directory is still within their home
// Normalizes a file/directory we get from the SFTP request to ensure the user is not able to escape
// from their data directory. After normalization if the file/directory is still within their home
// path it is returned. If they managed to "escape" an error will be returned.
func (c Connection) buildPath(rawPath string) (string, error) {
r := filepath.Clean(filepath.Join(c.User.HomeDir, rawPath))
@ -520,7 +521,7 @@ func (c Connection) buildPath(rawPath string) (string, error) {
if err != nil && !os.IsNotExist(err) {
return "", err
} else if os.IsNotExist(err) {
// The requested directory doesn't exist, so at this point we need to iterate up the
// The requested path doesn't exist, so at this point we need to iterate up the
// path chain until we hit a directory that _does_ exist and can be validated.
_, err = c.findFirstExistingDir(r)
if err != nil {
@ -602,8 +603,8 @@ func (c Connection) isSubDir(sub string) error {
return err
}
if !strings.HasPrefix(sub, parent) {
c.Log(logger.LevelWarn, logSender, "dir %#v is not inside: %#v ", sub, parent)
return fmt.Errorf("dir %#v is not inside: %#v", sub, parent)
c.Log(logger.LevelWarn, logSender, "path %#v is not inside: %#v ", sub, parent)
return fmt.Errorf("path %#v is not inside: %#v", sub, parent)
}
return nil
}

View file

@ -8,6 +8,7 @@ import (
"net"
"os"
"runtime"
"strings"
"testing"
"time"
@ -240,6 +241,107 @@ func TestSFTPGetUsedQuota(t *testing.T) {
}
}
func TestSupportedSSHCommands(t *testing.T) {
cmds := GetSupportedSSHCommands()
if len(cmds) != len(supportedSSHCommands) {
t.Errorf("supported ssh commands does not match")
}
for _, c := range cmds {
if !utils.IsStringInSlice(c, supportedSSHCommands) {
t.Errorf("invalid ssh command: %v", c)
}
}
}
func TestSSHCommandPath(t *testing.T) {
buf := make([]byte, 65535)
stdErrBuf := make([]byte, 65535)
mockSSHChannel := MockChannel{
Buffer: bytes.NewBuffer(buf),
StdErrBuffer: bytes.NewBuffer(stdErrBuf),
ReadError: nil,
}
connection := Connection{
channel: &mockSSHChannel,
}
sshCommand := sshCommand{
command: "test",
connection: connection,
args: []string{},
}
path := sshCommand.getDestPath()
if path != "" {
t.Errorf("path must be empty")
}
sshCommand.args = []string{"-t", "/tmp/../path"}
path = sshCommand.getDestPath()
if path != "/path" {
t.Errorf("unexpected path: %v", path)
}
sshCommand.args = []string{"-t", "/tmp/"}
path = sshCommand.getDestPath()
if path != "/tmp/" {
t.Errorf("unexpected path: %v", path)
}
sshCommand.args = []string{"-t", "tmp/"}
path = sshCommand.getDestPath()
if path != "/tmp/" {
t.Errorf("unexpected path: %v", path)
}
}
func TestSSHCommandErrors(t *testing.T) {
buf := make([]byte, 65535)
stdErrBuf := make([]byte, 65535)
readErr := fmt.Errorf("test read error")
mockSSHChannel := MockChannel{
Buffer: bytes.NewBuffer(buf),
StdErrBuffer: bytes.NewBuffer(stdErrBuf),
ReadError: readErr,
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
connection := Connection{
channel: &mockSSHChannel,
netConn: client,
}
cmd := sshCommand{
command: "md5sum",
connection: connection,
args: []string{},
}
err := cmd.handle()
if err == nil {
t.Errorf("ssh command must fail, we are sending a fake error")
}
cmd = sshCommand{
command: "md5sum",
connection: connection,
args: []string{"/../../test_file.dat"},
}
err = cmd.handle()
if err == nil {
t.Errorf("ssh command must fail, we are requesting an invalid path")
}
}
func TestGetConnectionInfo(t *testing.T) {
c := ConnectionStatus{
Username: "test_user",
ConnectionID: "123",
ClientVersion: "client",
RemoteAddress: "127.0.0.1:1234",
Protocol: protocolSSH,
SSHCommand: "sha1sum /test_file.dat",
}
info := c.GetConnectionInfo()
if !strings.Contains(info, "sha1sum /test_file.dat") {
t.Errorf("ssh command not found in connection info")
}
}
func TestSCPFileMode(t *testing.T) {
mode := getFileModeAsString(0, true)
if mode != "0755" {
@ -316,8 +418,11 @@ func TestSCPParseUploadMessage(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-t", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-t", "/tmp"},
},
}
_, _, err := scpCommand.parseUploadMessage("invalid")
if err == nil {
@ -352,8 +457,11 @@ func TestSCPProtocolMessages(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-t", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-t", "/tmp"},
},
}
_, err := scpCommand.readProtocolMessage()
if err == nil || err != readErr {
@ -414,8 +522,11 @@ func TestSCPTestDownloadProtocolMessages(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-f", "-p", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-f", "-p", "/tmp"},
},
}
path := "testDir"
os.Mkdir(path, 0777)
@ -483,8 +594,11 @@ func TestSCPCommandHandleErrors(t *testing.T) {
netConn: client,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-f", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-f", "/tmp"},
},
}
err := scpCommand.handle()
if err == nil || err != readErr {
@ -516,8 +630,11 @@ func TestSCPRecursiveDownloadErrors(t *testing.T) {
netConn: client,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-r", "-f", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-r", "-f", "/tmp"},
},
}
path := "testDir"
os.Mkdir(path, 0777)
@ -556,8 +673,11 @@ func TestSCPRecursiveUploadErrors(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-r", "-t", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-r", "-t", "/tmp"},
},
}
err := scpCommand.handleRecursiveUpload()
if err == nil {
@ -594,8 +714,11 @@ func TestSCPCreateDirs(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-r", "-t", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-r", "-t", "/tmp"},
},
}
err := scpCommand.handleCreateDir("invalid_dir")
if err == nil {
@ -625,8 +748,11 @@ func TestSCPDownloadFileData(t *testing.T) {
channel: &mockSSHChannelReadErr,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-r", "-f", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-r", "-f", "/tmp"},
},
}
ioutil.WriteFile(testfile, []byte("test"), 0666)
stat, _ := os.Stat(testfile)
@ -672,8 +798,11 @@ func TestSCPUploadFiledata(t *testing.T) {
channel: &mockSSHChannel,
}
scpCommand := scpCommand{
connection: connection,
args: []string{"-r", "-t", "/tmp"},
sshCommand: sshCommand{
command: "scp",
connection: connection,
args: []string{"-r", "-t", "/tmp"},
},
}
file, _ := os.Create(testfile)
transfer := Transfer{

View file

@ -14,7 +14,6 @@ import (
"github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"golang.org/x/crypto/ssh"
)
var (
@ -24,13 +23,8 @@ var (
newLine = []byte{0x0A}
)
type execMsg struct {
Command string
}
type scpCommand struct {
connection Connection
args []string
sshCommand
}
func (c *scpCommand) handle() error {
@ -494,16 +488,6 @@ func (c *scpCommand) handleDownload(filePath string) error {
return err
}
// returns the SCP destination path.
// We ensure that the path is absolute and in SFTP (UNIX) format
func (c *scpCommand) getDestPath() string {
destPath := filepath.ToSlash(c.args[len(c.args)-1])
if !filepath.IsAbs(destPath) {
destPath = "/" + destPath
}
return destPath
}
func (c *scpCommand) getCommandType() string {
return c.args[len(c.args)-2]
}
@ -597,21 +581,6 @@ func (c *scpCommand) sendProtocolMessage(message string) error {
return err
}
// sends the SCP command exit status
func (c *scpCommand) sendExitStatus(err error) {
status := uint32(0)
if err != nil {
status = uint32(1)
}
exitStatus := sshSubsystemExitStatus{
Status: status,
}
c.connection.Log(logger.LevelDebug, logSenderSCP, "send exit status for command with args: %v user: %v err: %v",
c.args, c.connection.User.Username, err)
c.connection.channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatus))
c.connection.channel.Close()
}
// get the next upload protocol message ignoring T command if any
// we use our own user setting for permissions
func (c *scpCommand) getNextUploadProtocolMessage() (string, error) {

View file

@ -14,7 +14,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@ -61,13 +60,8 @@ type Configuration struct {
// Keys are a list of host keys
Keys []Key `json:"keys" mapstructure:"keys"`
// IsSCPEnabled determines if experimental SCP support is enabled.
// We have our own SCP implementation since we can't rely on scp system
// command to properly handle permissions, quota and user's home dir restrictions.
// The SCP protocol is quite simple but there is no official docs about it,
// so we need more testing and feedbacks before enabling it by default.
// We may not handle some borderline cases or have sneaky bugs.
// Please do accurate tests yourself before enabling SCP and let us known
// if something does not work as expected for your use cases
// This setting is deprecated and will be removed in future versions,
// please add "scp" to the EnabledSSHCommands list to enable it.
IsSCPEnabled bool `json:"enable_scp" mapstructure:"enable_scp"`
// KexAlgorithms specifies the available KEX (Key Exchange) algorithms in
// preference order.
@ -83,6 +77,27 @@ type Configuration struct {
// SetstatMode 0 means "normal mode": requests for changing permissions and owner/group are executed.
// 1 means "ignore mode": requests for changing permissions and owner/group are silently ignored.
SetstatMode int `json:"setstat_mode" mapstructure:"setstat_mode"`
// List of enabled SSH commands.
// We support the following SSH commands:
// - "scp". SCP is an experimental feature, we have our own SCP implementation since
// we can't rely on scp system command to proper handle permissions, quota and
// user's home dir restrictions.
// The SCP protocol is quite simple but there is no official docs about it,
// so we need more testing and feedbacks before enabling it by default.
// We may not handle some borderline cases or have sneaky bugs.
// Please do accurate tests yourself before enabling SCP and let us known
// if something does not work as expected for your use cases.
// SCP between two remote hosts is supported using the `-3` scp option.
// - "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum". Useful to check message
// digests for uploaded files. These commands are implemented inside SFTPGo so they
// work even if the matching system commands are not available, for example on Windows.
// - "cd", "pwd". Some mobile SFTP clients does not support the SFTP SSH_FXP_REALPATH and so
// they use "cd" and "pwd" SSH commands to get the initial directory.
// Currently `cd` do nothing and `pwd` always returns the "/" path.
//
// The following SSH commands are enabled by default: "md5sum", "sha1sum", "cd", "pwd".
// "*" enables all supported SSH commands.
EnabledSSHCommands []string `json:"enabled_ssh_commands" mapstructure:"enabled_ssh_commands"`
}
// Key contains information about host keys
@ -159,6 +174,7 @@ func (c Configuration) Initialize(configDir string) error {
c.configureSecurityOptions(serverConfig)
c.configureLoginBanner(serverConfig, configDir)
c.configureSFTPExtensions()
c.checkSSHCommands()
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", c.BindAddress, c.BindPort))
if err != nil {
@ -298,24 +314,7 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
go c.handleSftpConnection(channel, connection)
}
case "exec":
if c.IsSCPEnabled {
var msg execMsg
if err := ssh.Unmarshal(req.Payload, &msg); err == nil {
name, scpArgs, err := parseCommandPayload(msg.Command)
connection.Log(logger.LevelDebug, logSender, "new exec command: %#v args: %v user: %v, error: %v",
name, scpArgs, connection.User.Username, err)
if err == nil && name == "scp" && len(scpArgs) >= 2 {
ok = true
connection.protocol = protocolSCP
connection.channel = channel
scpCommand := scpCommand{
connection: connection,
args: scpArgs,
}
go scpCommand.handle()
}
}
}
ok = processSSHCommand(req.Payload, &connection, channel, c.EnabledSSHCommands)
}
req.Reply(ok, nil)
}
@ -389,6 +388,26 @@ func loginUser(user dataprovider.User, loginType string) (*ssh.Permissions, erro
return p, nil
}
func (c *Configuration) checkSSHCommands() {
if utils.IsStringInSlice("*", c.EnabledSSHCommands) {
c.EnabledSSHCommands = GetSupportedSSHCommands()
return
}
sshCommands := []string{}
if c.IsSCPEnabled {
sshCommands = append(sshCommands, "scp")
}
for _, command := range c.EnabledSSHCommands {
if utils.IsStringInSlice(command, supportedSSHCommands) {
sshCommands = append(sshCommands, command)
} else {
logger.Warn(logSender, "", "unsupported ssh command: %#v ignored", command)
logger.WarnToConsole("unsupported ssh command: %#v ignored", command)
}
}
c.EnabledSSHCommands = sshCommands
}
// If no host keys are defined we try to use or generate the default one.
func (c *Configuration) checkHostKeys(configDir string) error {
var err error
@ -460,11 +479,3 @@ func (c Configuration) generatePrivateKey(file string) error {
return nil
}
func parseCommandPayload(command string) (string, []string, error) {
parts := strings.Split(command, " ")
if len(parts) < 2 {
return parts[0], []string{}, nil
}
return parts[0], parts[1:], nil
}

View file

@ -22,6 +22,7 @@ import (
const (
logSender = "sftpd"
logSenderSCP = "scp"
logSenderSSH = "ssh"
uploadLogSender = "Upload"
downloadLogSender = "Download"
renameLogSender = "Rename"
@ -38,6 +39,7 @@ const (
operationRename = "rename"
protocolSFTP = "SFTP"
protocolSCP = "SCP"
protocolSSH = "SSH"
handshakeTimeout = 2 * time.Minute
)
@ -58,6 +60,9 @@ var (
actions Actions
uploadMode int
setstatMode int
supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd"}
defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd"}
sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"}
)
type connectionTransfer struct {
@ -101,21 +106,41 @@ type ConnectionStatus struct {
ConnectionTime int64 `json:"connection_time"`
// Last activity as unix timestamp in milliseconds
LastActivity int64 `json:"last_activity"`
// Protocol for this connection: SFTP or SCP
// 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))
@ -123,9 +148,14 @@ func (c ConnectionStatus) GetConnectionDuration() string {
}
// GetConnectionInfo returns connection info.
// Protocol,Client Version and RemoteAddress are returned
// Protocol,Client Version and RemoteAddress are returned.
// For SSH commands the issued command is returned too.
func (c ConnectionStatus) GetConnectionInfo() string {
return fmt.Sprintf("%v. Client: %#v From: %#v", c.Protocol, c.ClientVersion, c.RemoteAddress)
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
@ -251,6 +281,7 @@ func GetConnectionsStats() []ConnectionStatus {
LastActivity: utils.GetTimeAsMsSinceEpoch(c.lastActivity),
Protocol: c.protocol,
Transfers: []connectionTransfer{},
SSHCommand: c.command,
}
for _, t := range activeTransfers {
if t.connectionID == c.ID {

View file

@ -4,7 +4,9 @@ import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"fmt"
"hash"
"io"
"io/ioutil"
"math"
@ -115,8 +117,8 @@ func TestMain(m *testing.M) {
"aes256-ctr"}
sftpdConf.MACs = []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-256"}
sftpdConf.LoginBannerFile = loginBannerFileName
// we need to test SCP support
sftpdConf.IsSCPEnabled = true
// we need to test all supported ssh commands
sftpdConf.EnabledSSHCommands = []string{"*"}
// we run the test cases with UploadMode atomic and resume support. The non atomic code path
// simply does not execute some code so if it works in atomic mode will
// work in non atomic mode too
@ -178,6 +180,8 @@ func TestInitialization(t *testing.T) {
sftpdConf.Umask = "invalid umask"
sftpdConf.BindPort = 2022
sftpdConf.LoginBannerFile = "invalid_file"
sftpdConf.IsSCPEnabled = true
sftpdConf.EnabledSSHCommands = append(sftpdConf.EnabledSSHCommands, "ls")
err := sftpdConf.Initialize(configDir)
if err == nil {
t.Errorf("Inizialize must fail, a SFTP server should be already running")
@ -291,11 +295,11 @@ func TestUploadResume(t *testing.T) {
if err != nil {
t.Errorf("file download error: %v", err)
}
initialHash, err := computeFileHash(localDownloadPath)
initialHash, err := computeHashForFile(sha256.New(), testFilePath)
if err != nil {
t.Errorf("error computing file hash: %v", err)
}
donwloadedFileHash, err := computeFileHash(localDownloadPath)
donwloadedFileHash, err := computeHashForFile(sha256.New(), localDownloadPath)
if err != nil {
t.Errorf("error computing downloaded file hash: %v", err)
}
@ -2065,15 +2069,60 @@ func TestPermChtimes(t *testing.T) {
os.RemoveAll(user.GetHomeDir())
}
func TestSSHConnection(t *testing.T) {
func TestSSHCommands(t *testing.T) {
usePubKey := false
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
if err != nil {
t.Errorf("unable to add user: %v", err)
}
err = doSSH(user, usePubKey)
_, err = runSSHCommand("ls", user, usePubKey)
if err == nil {
t.Errorf("ssh connection must fail: %v", err)
t.Errorf("unsupported ssh command must fail")
}
_, err = runSSHCommand("cd", user, usePubKey)
if err != nil {
t.Errorf("unexpected error for ssh cd command: %v", err)
}
out, err := runSSHCommand("pwd", user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
if string(out) != "/\n" {
t.Errorf("invalid response for ssh pwd command: %v", string(out))
}
out, err = runSSHCommand("md5sum", user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
// echo -n '' | md5sum
if !strings.Contains(string(out), "d41d8cd98f00b204e9800998ecf8427e") {
t.Errorf("invalid md5sum: %v", string(out))
}
out, err = runSSHCommand("sha1sum", user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
if !strings.Contains(string(out), "da39a3ee5e6b4b0d3255bfef95601890afd80709") {
t.Errorf("invalid sha1sum: %v", string(out))
}
out, err = runSSHCommand("sha256sum", user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
if !strings.Contains(string(out), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") {
t.Errorf("invalid sha256sum: %v", string(out))
}
out, err = runSSHCommand("sha384sum", user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
if !strings.Contains(string(out), "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b") {
t.Errorf("invalid sha384sum: %v", string(out))
}
_, err = httpd.RemoveUser(user, http.StatusOK)
if err != nil {
@ -2081,6 +2130,52 @@ func TestSSHConnection(t *testing.T) {
}
}
func TestSSHFileHash(t *testing.T) {
usePubKey := true
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
if err != nil {
t.Errorf("unable to add user: %v", err)
}
client, err := getSftpClient(user, usePubKey)
if err != nil {
t.Errorf("unable to create sftp client: %v", err)
} else {
defer client.Close()
testFileName := "test_file.dat"
testFilePath := filepath.Join(homeBasePath, testFileName)
testFileSize := int64(65535)
err = createTestFile(testFilePath, testFileSize)
if err != nil {
t.Errorf("unable to create test file: %v", err)
}
err = sftpUploadFile(testFilePath, testFileName, testFileSize, client)
if err != nil {
t.Errorf("file upload error: %v", err)
}
initialHash, err := computeHashForFile(sha512.New(), testFilePath)
if err != nil {
t.Errorf("error computing file hash: %v", err)
}
out, err := runSSHCommand("sha512sum "+testFileName, user, usePubKey)
if err != nil {
t.Errorf("unexpected error: %v", err)
t.Fail()
}
if !strings.Contains(string(out), initialHash) {
t.Errorf("invalid sha512sum: %v", string(out))
}
_, err = runSSHCommand("sha512sum invalid_path", user, usePubKey)
if err == nil {
t.Errorf("hash for an invalid path must fail")
}
}
_, err = httpd.RemoveUser(user, http.StatusOK)
if err != nil {
t.Errorf("unable to remove user: %v", err)
}
os.RemoveAll(user.GetHomeDir())
}
// Start SCP tests
func TestSCPBasicHandling(t *testing.T) {
if len(scpPath) == 0 {
@ -2777,8 +2872,9 @@ func getTestUser(usePubKey bool) dataprovider.User {
return user
}
func doSSH(user dataprovider.User, usePubKey bool) error {
func runSSHCommand(command string, user dataprovider.User, usePubKey bool) ([]byte, error) {
var sshSession *ssh.Session
var output []byte
config := &ssh.ClientConfig{
User: defaultUsername,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
@ -2788,7 +2884,7 @@ func doSSH(user dataprovider.User, usePubKey bool) error {
if usePubKey {
key, err := ssh.ParsePrivateKey([]byte(testPrivateKey))
if err != nil {
return err
return output, err
}
config.Auth = []ssh.AuthMethod{ssh.PublicKeys(key)}
} else {
@ -2796,15 +2892,21 @@ func doSSH(user dataprovider.User, usePubKey bool) error {
}
conn, err := ssh.Dial("tcp", sftpServerAddr, config)
if err != nil {
return err
return output, err
}
defer conn.Close()
sshSession, err = conn.NewSession()
if err != nil {
return err
return output, err
}
_, err = sshSession.CombinedOutput("ls")
return err
var stdout, stderr bytes.Buffer
sshSession.Stdout = &stdout
sshSession.Stderr = &stderr
err = sshSession.Run(command)
if err != nil {
return nil, fmt.Errorf("failed to run command %v: %v", command, stderr.Bytes())
}
return stdout.Bytes(), err
}
func getSftpClient(user dataprovider.User, usePubKey bool) (*sftp.Client, error) {
@ -3047,18 +3149,17 @@ func getScpUploadCommand(localPath, remotePath string, preserveTime, remoteToRem
return exec.Command(scpPath, args...)
}
func computeFileHash(path string) (string, error) {
func computeHashForFile(hasher hash.Hash, path string) (string, error) {
hash := ""
f, err := os.Open(path)
if err != nil {
return hash, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return hash, err
_, err = io.Copy(hasher, f)
if err == nil {
hash = fmt.Sprintf("%x", hasher.Sum(nil))
}
hash = fmt.Sprintf("%x", h.Sum(nil))
return hash, err
}

173
sftpd/ssh_cmd.go Normal file
View file

@ -0,0 +1,173 @@
package sftpd
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"fmt"
"hash"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/drakkan/sftpgo/logger"
"github.com/drakkan/sftpgo/utils"
"golang.org/x/crypto/ssh"
)
type sshCommand struct {
command string
args []string
connection Connection
}
func processSSHCommand(payload []byte, connection *Connection, channel ssh.Channel, enabledSSHCommands []string) bool {
var msg sshSubsystemExecMsg
if err := ssh.Unmarshal(payload, &msg); err == nil {
name, args, err := parseCommandPayload(msg.Command)
connection.Log(logger.LevelDebug, logSenderSSH, "new ssh command: %#v args: %v user: %v, error: %v",
name, args, connection.User.Username, err)
if err == nil && utils.IsStringInSlice(name, enabledSSHCommands) {
connection.command = fmt.Sprintf("%v %v", name, strings.Join(args, " "))
if name == "scp" && len(args) >= 2 {
connection.protocol = protocolSCP
connection.channel = channel
scpCommand := scpCommand{
sshCommand: sshCommand{
command: name,
connection: *connection,
args: args},
}
go scpCommand.handle()
return true
}
if name != "scp" {
connection.protocol = protocolSSH
connection.channel = channel
sshCommand := sshCommand{
command: name,
connection: *connection,
args: args,
}
go sshCommand.handle()
return true
}
} else {
connection.Log(logger.LevelInfo, logSenderSSH, "ssh command not enabled/supported: %#v", name)
}
}
return false
}
func (c *sshCommand) handle() error {
addConnection(c.connection)
defer removeConnection(c.connection)
updateConnectionActivity(c.connection.ID)
if utils.IsStringInSlice(c.command, sshHashCommands) {
var h hash.Hash
if c.command == "md5sum" {
h = md5.New()
} else if c.command == "sha1sum" {
h = sha1.New()
} else if c.command == "sha256sum" {
h = sha256.New()
} else if c.command == "sha384sum" {
h = sha512.New384()
} else {
h = sha512.New()
}
var response string
if len(c.args) == 0 {
// without args we need to read the string to hash from stdin
buf := make([]byte, 4096)
n, err := c.connection.channel.Read(buf)
if err != nil && err != io.EOF {
return c.sendErrorResponse(err)
}
h.Write(buf[:n])
response = fmt.Sprintf("%x -\n", h.Sum(nil))
} else {
sshPath := c.getDestPath()
path, err := c.connection.buildPath(sshPath)
if err != nil {
return c.sendErrorResponse(err)
}
hash, err := computeHashForFile(h, path)
if err != nil {
return c.sendErrorResponse(err)
}
response = fmt.Sprintf("%v %v\n", hash, sshPath)
}
c.connection.channel.Write([]byte(response))
c.sendExitStatus(nil)
} else if c.command == "cd" {
c.sendExitStatus(nil)
} else if c.command == "pwd" {
// hard coded response to "/"
c.connection.channel.Write([]byte("/\n"))
c.sendExitStatus(nil)
}
return nil
}
// for the supported command, the path, if any, is the last argument
func (c *sshCommand) getDestPath() string {
if len(c.args) == 0 {
return ""
}
destPath := filepath.ToSlash(c.args[len(c.args)-1])
if !path.IsAbs(destPath) {
destPath = "/" + destPath
}
result := path.Clean(destPath)
if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") {
result += "/"
}
return result
}
func (c *sshCommand) sendErrorResponse(err error) error {
errorString := fmt.Sprintf("%v: %v %v\n", c.command, c.getDestPath(), err)
c.connection.channel.Write([]byte(errorString))
c.sendExitStatus(err)
return err
}
func (c *sshCommand) sendExitStatus(err error) {
status := uint32(0)
if err != nil {
status = uint32(1)
}
exitStatus := sshSubsystemExitStatus{
Status: status,
}
c.connection.Log(logger.LevelDebug, logSenderSSH, "send exit status for command %#v with args: %v user: %v err: %v",
c.command, c.args, c.connection.User.Username, err)
c.connection.channel.SendRequest("exit-status", false, ssh.Marshal(&exitStatus))
c.connection.channel.Close()
}
func computeHashForFile(hasher hash.Hash, path string) (string, error) {
hash := ""
f, err := os.Open(path)
if err != nil {
return hash, err
}
defer f.Close()
_, err = io.Copy(hasher, f)
if err == nil {
hash = fmt.Sprintf("%x", hasher.Sum(nil))
}
return hash, err
}
func parseCommandPayload(command string) (string, []string, error) {
parts := strings.Split(command, " ")
if len(parts) < 2 {
return parts[0], []string{}, nil
}
return parts[0], parts[1:], nil
}

View file

@ -18,7 +18,8 @@
"ciphers": [],
"macs": [],
"login_banner_file": "",
"setstat_mode": 0
"setstat_mode": 0,
"enabled_ssh_commands": ["md5sum", "sha1sum", "cd", "pwd"]
},
"data_provider": {
"driver": "sqlite",