diff --git a/go.mod b/go.mod index e1a2464c..735e32fd 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/scripthaus-dev/mshell go 1.17 require ( + github.com/alessio/shellescape v1.4.1 // indirect github.com/creack/pty v1.1.18 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/google/uuid v1.3.0 // indirect diff --git a/go.sum b/go.sum index fd7b5ca7..d802966a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= diff --git a/main-mshell.go b/main-mshell.go index ea8df615..2b3e4116 100644 --- a/main-mshell.go +++ b/main-mshell.go @@ -211,7 +211,7 @@ func doMain() { } } -func handleRemote() { +func handleSingle() { packetParser := packet.MakePacketParser(os.Stdin) sender := packet.MakePacketSender(os.Stdout) defer func() { @@ -285,14 +285,35 @@ func parseClientOpts() (*shexec.ClientOpts, error) { for iter.HasNext() { argStr := iter.Next() if argStr == "--ssh" { - if opts.IsSSH { - return nil, fmt.Errorf("duplicate '--ssh' option") + if !iter.IsNextPlain() { + return nil, fmt.Errorf("'--ssh [user@host]' missing host") } - opts.IsSSH = true - break + opts.SSHHost = iter.Next() + continue + } + if argStr == "--ssh-opts" { + if !iter.HasNext() { + return nil, fmt.Errorf("'--ssh-opts [options]' missing options") + } + opts.SSHOptsStr = iter.Next() + continue + } + if argStr == "-i" { + if !iter.IsNextPlain() { + return nil, fmt.Errorf("-i [identity-file]' missing file") + } + opts.SSHIdentity = iter.Next() + continue + } + if argStr == "-l" { + if !iter.IsNextPlain() { + return nil, fmt.Errorf("-l [user]' missing user") + } + opts.SSHUser = iter.Next() + continue } if argStr == "--cwd" { - if !iter.HasNext() { + if !iter.IsNextPlain() { return nil, fmt.Errorf("'--cwd [dir]' missing directory") } opts.Cwd = iter.Next() @@ -316,7 +337,7 @@ func parseClientOpts() (*shexec.ClientOpts, error) { continue } if argStr == "--sudo-with-passfile" { - if !iter.HasNext() { + if !iter.IsNextPlain() { return nil, fmt.Errorf("'--sudo-with-passfile [file]', missing file") } opts.Sudo = true @@ -329,29 +350,12 @@ func parseClientOpts() (*shexec.ClientOpts, error) { opts.SudoPw = string(contents) continue } - } - if opts.IsSSH { - // parse SSH opts - for iter.HasNext() { - argStr := iter.Next() - if argStr == "--" { - opts.SSHOptsTerm = true - break + if argStr == "--" { + if !iter.HasNext() { + return nil, fmt.Errorf("'--' should be followed by command") } - if argStr == "-t" || argStr == "-tt" { - return nil, fmt.Errorf("mshell cannot run over ssh -t") - } - opts.SSHOpts = append(opts.SSHOpts, argStr) - } - if !opts.SSHOptsTerm { - return nil, fmt.Errorf("ssh options must be terminated with '--' followed by [command]") - } - if !iter.HasNext() { - return nil, fmt.Errorf("no command specified") - } - opts.Command = strings.Join(iter.Rest(), " ") - if strings.TrimSpace(opts.Command) == "" { - return nil, fmt.Errorf("no command or empty command specified") + opts.Command = strings.Join(iter.Rest(), " ") + break } } return opts, nil @@ -365,9 +369,12 @@ func handleClient() (int, error) { if opts.Debug { packet.GlobalDebug = true } - if !opts.IsSSH { + if opts.SSHHost == "" { return 1, fmt.Errorf("when running in client mode '--ssh' option must be present") } + if opts.Command == "" { + return 1, fmt.Errorf("no [command] specified. [command] follows '--' option (see usage)") + } fds, err := detectOpenFds() if err != nil { return 1, err @@ -382,17 +389,20 @@ func handleClient() (int, error) { func handleUsage() { usage := ` -Client Usage: mshell [mshell-opts] --ssh [ssh-opts] user@host -- [command] +Client Usage: mshell [opts] --ssh user@host -- [command] mshell multiplexes input and output streams to a remote command over ssh. Options: - --cwd [dir] - execute remote command in [dir] - [command] - a single argument (should be quoted) + -i [identity-file] - used to set '-i' option for ssh command + -l [user] - used to set '-l' option for ssh command + --cwd [dir] - execute remote command in [dir] + --ssh-opts [opts] - addition options to pass to ssh command + [command] - the remote command to execute Sudo Options: - --sudo - --sudo-with-password [pw] (not recommended, use --sudo-with-passfile if possible) + --sudo - use only if sudo never requires a password + --sudo-with-password [pw] - not recommended, use --sudo-with-passfile if possible --sudo-with-passfile [file] Sudo options allow you to run the given command using "sudo". The first @@ -401,7 +411,7 @@ securely through a high numbered fd to "sudo -S". See full documentation for mo Examples: # execute a python script remotely, with stdin still hooked up correctly - mshell --cwd "~/work" --ssh -i key.pem ubuntu@somehost -- "python3 /dev/fd/4" 4< myscript.py + mshell --cwd "~/work" -i key.pem --ssh ubuntu@somehost -- "python3 /dev/fd/4" 4< myscript.py # capture multiple outputs mshell --ssh ubuntu@test -- "cat file1.txt > /dev/fd/3; cat file2.txt > /dev/fd/4" 3> file1.txt 4> file2.txt @@ -431,8 +441,8 @@ func main() { } else if firstArg == "--version" { fmt.Printf("mshell v%s\n", MShellVersion) return - } else if firstArg == "--remote" { - handleRemote() + } else if firstArg == "--single" { + handleSingle() return } else if firstArg == "--server" { handleServer() diff --git a/pkg/base/optsiter.go b/pkg/base/optsiter.go index 329455ba..923d86d2 100644 --- a/pkg/base/optsiter.go +++ b/pkg/base/optsiter.go @@ -25,6 +25,13 @@ func (iter *OptsIter) HasNext() bool { return iter.Pos <= len(iter.Opts)-1 } +func (iter *OptsIter) IsNextPlain() bool { + if !iter.HasNext() { + return false + } + return !IsOption(iter.Opts[iter.Pos]) +} + func (iter *OptsIter) Next() string { if iter.Pos >= len(iter.Opts) { return "" diff --git a/pkg/packet/packet.go b/pkg/packet/packet.go index 87848fce..3638a04f 100644 --- a/pkg/packet/packet.go +++ b/pkg/packet/packet.go @@ -340,6 +340,7 @@ type InitPacketType struct { HomeDir string `json:"homedir,omitempty"` Env []string `json:"env,omitempty"` User string `json:"user,omitempty"` + NotFound bool `json:"notfound,omitempty"` } func (*InitPacketType) GetType() string { diff --git a/pkg/shexec/shexec.go b/pkg/shexec/shexec.go index fad15ca7..b81cae1e 100644 --- a/pkg/shexec/shexec.go +++ b/pkg/shexec/shexec.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/alessio/shellescape" "github.com/creack/pty" "github.com/scripthaus-dev/mshell/pkg/base" "github.com/scripthaus-dev/mshell/pkg/mpio" @@ -29,11 +30,20 @@ const MaxCols = 1024 const MaxFdNum = 1023 const FirstExtraFilesFdNum = 3 -const SSHRemoteCommand = `PATH=$PATH:~/.mshell; mshell --remote` +const SSHRemoteCommand = ` +PATH=$PATH:~/.mshell; +which mshell > /dev/null; +if [[ "$?" -ne 0 ]] +then + printf "\n##34{\"type\": \"init\", \"notfound\": true}\n" +else + mshell --single +fi +` -const RemoteCommandFmt = `%s` -const RemoteSudoCommandFmt = `sudo -C %d bash /dev/fd/%d` -const RemoteSudoPasswordCommandFmt = `cat /dev/fd/%d | sudo -S -C %d bash -c "echo '[from-mshell]'; bash /dev/fd/%d < /dev/fd/%d"` +const RunCommandFmt = `%s` +const RunSudoCommandFmt = `sudo -C %d bash /dev/fd/%d` +const RunSudoPasswordCommandFmt = `cat /dev/fd/%d | sudo -S -C %d bash -c "echo '[from-mshell]'; bash /dev/fd/%d < /dev/fd/%d"` type ShExecType struct { Lock *sync.Mutex @@ -205,9 +215,10 @@ func RunCommand(pk *packet.RunPacketType, sender *packet.PacketSender) (*ShExecT } type ClientOpts struct { - IsSSH bool - SSHOptsTerm bool - SSHOpts []string + SSHHost string + SSHOptsStr string + SSHIdentity string + SSHUser string Command string Fds []packet.RemoteFd Cwd string @@ -218,13 +229,29 @@ type ClientOpts struct { CommandStdinFdNum int } +func (opts *ClientOpts) MakeSSHCommandString() string { + var moreSSHOpts []string + if opts.SSHIdentity != "" { + identityOpt := fmt.Sprintf("-i %s", shellescape.Quote(opts.SSHIdentity)) + moreSSHOpts = append(moreSSHOpts, identityOpt) + } + if opts.SSHUser != "" { + userOpt := fmt.Sprintf("-l %s", shellescape.Quote(opts.SSHUser)) + moreSSHOpts = append(moreSSHOpts, userOpt) + } + remoteCommand := strings.TrimSpace(SSHRemoteCommand) + // note that SSHOptsStr is *not* escaped + sshCmd := fmt.Sprintf("ssh %s %s %s %s", strings.Join(moreSSHOpts, " "), opts.SSHOptsStr, shellescape.Quote(opts.SSHHost), shellescape.Quote(remoteCommand)) + return sshCmd +} + func (opts *ClientOpts) MakeRunPacket() (*packet.RunPacketType, error) { runPacket := packet.MakeRunPacket() runPacket.Cwd = opts.Cwd runPacket.Fds = opts.Fds if !opts.Sudo { // normal, non-sudo command - runPacket.Command = opts.Command + runPacket.Command = fmt.Sprintf(RunCommandFmt, opts.Command) return runPacket, nil } if opts.SudoWithPass { @@ -248,7 +275,7 @@ func (opts *ClientOpts) MakeRunPacket() (*packet.RunPacketType, error) { opts.Fds = append(opts.Fds, commandStdinRfd) opts.CommandStdinFdNum = commandStdinFdNum maxFdNum := opts.MaxFdNum() - runPacket.Command = fmt.Sprintf(RemoteSudoPasswordCommandFmt, pwFdNum, maxFdNum+1, commandFdNum, commandStdinFdNum) + runPacket.Command = fmt.Sprintf(RunSudoPasswordCommandFmt, pwFdNum, maxFdNum+1, commandFdNum, commandStdinFdNum) runPacket.Fds = opts.Fds return runPacket, nil } else { @@ -259,7 +286,7 @@ func (opts *ClientOpts) MakeRunPacket() (*packet.RunPacketType, error) { rfd := packet.RemoteFd{FdNum: commandFdNum, Read: true, Content: opts.Command} opts.Fds = append(opts.Fds, rfd) maxFdNum := opts.MaxFdNum() - runPacket.Command = fmt.Sprintf(RemoteSudoCommandFmt, maxFdNum+1, commandFdNum) + runPacket.Command = fmt.Sprintf(RunSudoCommandFmt, maxFdNum+1, commandFdNum) runPacket.Fds = opts.Fds return runPacket, nil } @@ -324,10 +351,8 @@ func RunClientSSHCommandAndWait(opts *ClientOpts) (*packet.CmdDonePacketType, er return nil, err } cmd := MakeShExec("") - var fullSshOpts []string - fullSshOpts = append(fullSshOpts, opts.SSHOpts...) - fullSshOpts = append(fullSshOpts, SSHRemoteCommand) - ecmd := exec.Command("ssh", fullSshOpts...) + sshCmdStr := opts.MakeSSHCommandString() + ecmd := exec.Command("bash", "-c", sshCmdStr) cmd.Cmd = ecmd inputWriter, err := ecmd.StdinPipe() if err != nil { @@ -386,6 +411,9 @@ func RunClientSSHCommandAndWait(opts *ClientOpts) (*packet.CmdDonePacketType, er } if pk.GetType() == packet.InitPacketStr { initPk := pk.(*packet.InitPacketType) + if initPk.NotFound { + return nil, fmt.Errorf("mshell command not found on remote server, can install with 'mshell --install'") + } if initPk.Version != "0.1.0" { return nil, fmt.Errorf("invalid remote mshell version 'v%s', must be v0.1.0", initPk.Version) }