create proof of concept ssh library integration

This is a first attempt to integrate the golang crypto/ssh library for
handling remote connections. As it stands, this features is limited to
identity files without passphrases. It needs to be expanded to include
key+passphrase and password verifications as well.
This commit is contained in:
Sylvia Crowe 2024-01-22 23:18:49 -08:00
parent 1b81b2906c
commit 03ea444030
4 changed files with 468 additions and 21 deletions

View File

@ -651,7 +651,7 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) {
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err))
return
}
cproc, _, err := shexec.MakeClientProc(context.Background(), ecmd)
cproc, _, err := shexec.MakeClientProc(context.Background(), shexec.CmdWrap{Cmd: ecmd})
if err != nil {
m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("starting mshell client: %s", err))
return

View File

@ -12,6 +12,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"golang.org/x/crypto/ssh"
"golang.org/x/mod/semver"
)
@ -19,8 +20,97 @@ import (
const NotFoundVersion = "v0.0"
type CmdWrap struct {
Cmd *exec.Cmd
}
func (cw CmdWrap) Kill() {
cw.Cmd.Process.Kill()
}
func (cw CmdWrap) Wait() error {
return cw.Cmd.Wait()
}
func (cw CmdWrap) Sender() (*packet.PacketSender, io.WriteCloser, error) {
inputWriter, err := cw.Cmd.StdinPipe()
if err != nil {
return nil, nil, fmt.Errorf("creating stdin pipe: %v", err)
}
sender := packet.MakePacketSender(inputWriter, nil)
return sender, inputWriter, nil
}
func (cw CmdWrap) Parser() (*packet.PacketParser, io.ReadCloser, io.ReadCloser, error) {
stdoutReader, err := cw.Cmd.StdoutPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("creating stdout pipe: %v", err)
}
stderrReader, err := cw.Cmd.StderrPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("creating stderr pipe: %v", err)
}
stdoutPacketParser := packet.MakePacketParser(stdoutReader, &packet.PacketParserOpts{IgnoreUntilValid: true})
stderrPacketParser := packet.MakePacketParser(stderrReader, nil)
packetParser := packet.CombinePacketParsers(stdoutPacketParser, stderrPacketParser, true)
return packetParser, stdoutReader, stderrReader, nil
}
func (cw CmdWrap) Start() error {
return cw.Cmd.Start()
}
type SessionWrap struct {
Session *ssh.Session
StartCmd string
}
func (sw SessionWrap) Kill() {
sw.Session.Close()
}
func (sw SessionWrap) Wait() error {
return sw.Session.Wait()
}
func (sw SessionWrap) Start() error {
return sw.Session.Start(sw.StartCmd)
}
func (sw SessionWrap) Sender() (*packet.PacketSender, io.WriteCloser, error) {
inputWriter, err := sw.Session.StdinPipe()
if err != nil {
return nil, nil, fmt.Errorf("creating stdin pipe: %v", err)
}
sender := packet.MakePacketSender(inputWriter, nil)
return sender, inputWriter, nil
}
func (sw SessionWrap) Parser() (*packet.PacketParser, io.ReadCloser, io.ReadCloser, error) {
stdoutReader, err := sw.Session.StdoutPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("creating stdout pipe: %v", err)
}
stderrReader, err := sw.Session.StderrPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("creating stderr pipe: %v", err)
}
stdoutPacketParser := packet.MakePacketParser(stdoutReader, &packet.PacketParserOpts{IgnoreUntilValid: true})
stderrPacketParser := packet.MakePacketParser(stderrReader, nil)
packetParser := packet.CombinePacketParsers(stdoutPacketParser, stderrPacketParser, true)
return packetParser, io.NopCloser(stdoutReader), io.NopCloser(stderrReader), nil
}
type ConnInterface interface {
Kill()
Wait() error
Sender() (*packet.PacketSender, io.WriteCloser, error)
Parser() (*packet.PacketParser, io.ReadCloser, io.ReadCloser, error)
Start() error
}
type ClientProc struct {
Cmd *exec.Cmd
Cmd ConnInterface
InitPk *packet.InitPacketType
StartTs time.Time
StdinWriter io.WriteCloser
@ -31,28 +121,20 @@ type ClientProc struct {
}
// returns (clientproc, initpk, error)
func MakeClientProc(ctx context.Context, ecmd *exec.Cmd) (*ClientProc, *packet.InitPacketType, error) {
inputWriter, err := ecmd.StdinPipe()
if err != nil {
return nil, nil, fmt.Errorf("creating stdin pipe: %v", err)
}
stdoutReader, err := ecmd.StdoutPipe()
if err != nil {
return nil, nil, fmt.Errorf("creating stdout pipe: %v", err)
}
stderrReader, err := ecmd.StderrPipe()
if err != nil {
return nil, nil, fmt.Errorf("creating stderr pipe: %v", err)
}
func MakeClientProc(ctx context.Context, ecmd ConnInterface) (*ClientProc, *packet.InitPacketType, error) {
startTs := time.Now()
sender, inputWriter, err := ecmd.Sender()
if err != nil {
return nil, nil, err
}
packetParser, stdoutReader, stderrReader, err := ecmd.Parser()
if err != nil {
return nil, nil, err
}
err = ecmd.Start()
if err != nil {
return nil, nil, fmt.Errorf("running local client: %w", err)
}
sender := packet.MakePacketSender(inputWriter, nil)
stdoutPacketParser := packet.MakePacketParser(stdoutReader, &packet.PacketParserOpts{IgnoreUntilValid: true})
stderrPacketParser := packet.MakePacketParser(stderrReader, nil)
packetParser := packet.CombinePacketParsers(stdoutPacketParser, stderrPacketParser, true)
cproc := &ClientProc{
Cmd: ecmd,
StartTs: startTs,
@ -107,7 +189,7 @@ func (cproc *ClientProc) Close() {
cproc.StderrReader.Close()
}
if cproc.Cmd != nil {
cproc.Cmd.Process.Kill()
cproc.Cmd.Kill()
}
}

View File

@ -24,6 +24,7 @@ import (
"github.com/alessio/shellescape"
"github.com/creack/pty"
"github.com/google/uuid"
"github.com/kevinburke/ssh_config"
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/cirfile"
"github.com/wavetermdev/waveterm/waveshell/pkg/mpio"
@ -31,6 +32,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/shellapi"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellenv"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellutil"
"golang.org/x/crypto/ssh"
"golang.org/x/mod/semver"
"golang.org/x/sys/unix"
)
@ -476,6 +478,76 @@ func (opts SSHOpts) MakeSSHExecCmd(remoteCommand string, sapi shellapi.ShellApi)
}
}
func (opts SSHOpts) ConnectToClient() (*ssh.Client, error) {
ssh_config.ReloadConfigs()
configIdentity, _ := ssh_config.GetStrict(opts.SSHHost, "IdentityFile")
var identityFile string
if opts.SSHIdentity != "" {
identityFile = opts.SSHIdentity
} else {
identityFile = configIdentity
}
var authMethods []ssh.AuthMethod
var hostKeyCallback ssh.HostKeyCallback
if identityFile != "" {
sshKeyFile, err := os.ReadFile(base.ExpandHomeDir(identityFile))
if err != nil {
return nil, fmt.Errorf("failed to read ssh key file. err: %+v", err)
}
signer, err := ssh.ParsePrivateKey(sshKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to parse private ssh key. err: %+v", err)
}
/*
publicKey, err := ssh.ParsePublicKey(sshKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to parse public ssh key. err: %+v", err)
}
*/
authMethods = append(authMethods, ssh.PublicKeys(signer))
hostKeyCallback = ssh.InsecureIgnoreHostKey()
} else {
hostKeyCallback = ssh.InsecureIgnoreHostKey()
}
configUser, _ := ssh_config.GetStrict(opts.SSHHost, "User")
configHostName, _ := ssh_config.GetStrict(opts.SSHHost, "HostName")
configPort, _ := ssh_config.GetStrict(opts.SSHHost, "Port")
var username string
if opts.SSHUser != "" {
username = opts.SSHUser
} else if configUser != "" {
username = configUser
} else {
user, err := user.Current()
if err != nil {
return nil, fmt.Errorf("failed to get user for ssh: %+v", err)
}
username = user.Username
}
var hostName string
if configHostName != "" {
hostName = configHostName
} else {
hostName = opts.SSHHost
}
clientConfig := &ssh.ClientConfig{
User: username,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
}
var port string
if opts.SSHPort != 0 && opts.SSHPort != 22 {
port = strconv.Itoa(opts.SSHPort)
} else if configPort != "" && configPort != "22" {
port = configPort
} else {
port = "22"
}
networkAddr := hostName + ":" + port
return ssh.Dial("tcp", networkAddr, clientConfig)
}
func (opts SSHOpts) MakeMShellSSHOpts() string {
var moreSSHOpts []string
if opts.SSHIdentity != "" {

View File

@ -36,6 +36,7 @@ import (
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scpacket"
"github.com/wavetermdev/waveterm/wavesrv/pkg/sstore"
"golang.org/x/crypto/ssh"
"golang.org/x/mod/semver"
)
@ -77,6 +78,12 @@ else
fi
`
const WaveshellServerRunOnlyFmt = `
PATH=$PATH:~/.mshell;
[%PINGPACKET%]
mshell-[%VERSION%] --server
`
func MakeLocalMShellCommandStr(isSudo bool) (string, error) {
mshellPath, err := scbase.LocalMShellBinaryPath()
if err != nil {
@ -95,6 +102,13 @@ func MakeServerCommandStr() string {
return rtn
}
func MakeServerRunOnlyCommandStr() string {
rtn := strings.ReplaceAll(WaveshellServerRunOnlyFmt, "[%VERSION%]", semver.MajorMinor(scbase.MShellVersion))
rtn = strings.ReplaceAll(rtn, "[%PINGPACKET%]", PrintPingPacket)
return rtn
}
const (
StatusConnected = sstore.RemoteStatus_Connected
StatusConnecting = sstore.RemoteStatus_Connecting
@ -1206,7 +1220,286 @@ func (msh *MShellProc) getActiveShellTypes(ctx context.Context) ([]string, error
return utilfn.CombineStrArrays(rtn, activeShells), nil
}
func (msh *MShellProc) LaunchWithSshLib(interactive bool) {
remoteCopy := msh.GetRemoteCopy()
if remoteCopy.Archived {
msh.WriteToPtyBuffer("cannot launch archived remote\n")
return
}
curStatus := msh.GetStatus()
if curStatus == StatusConnected {
msh.WriteToPtyBuffer("remote is already connected (no action taken)\n")
return
}
if curStatus == StatusConnecting {
msh.WriteToPtyBuffer("remote is already connecting, disconnect before trying to connect again\n")
return
}
istatus := msh.GetInstallStatus()
if istatus == StatusConnecting {
msh.WriteToPtyBuffer("remote is trying to install, cancel install before trying to connect again\n")
return
}
if remoteCopy.SSHOpts.SSHPort != 0 && remoteCopy.SSHOpts.SSHPort != 22 {
msh.WriteToPtyBuffer("connecting to %s (port %d)...\n", remoteCopy.RemoteCanonicalName, remoteCopy.SSHOpts.SSHPort)
} else {
msh.WriteToPtyBuffer("connecting to %s...\n", remoteCopy.RemoteCanonicalName)
}
sshOpts := convertSSHOpts(remoteCopy.SSHOpts)
sshOpts.SSHErrorsToTty = true
if remoteCopy.ConnectMode != sstore.ConnectModeManual && remoteCopy.SSHOpts.SSHPassword == "" && !interactive {
sshOpts.BatchMode = true
}
client, err := sshOpts.ConnectToClient()
if err != nil {
msh.WriteToPtyBuffer("*error, ssh cannot connect to client: %v\n", err)
}
makeClientCtx, makeClientCancelFn := context.WithCancel(context.Background())
defer makeClientCancelFn()
msh.WithLock(func() {
msh.Err = nil
msh.ErrNoInitPk = false
msh.Status = StatusConnecting
msh.MakeClientCancelFn = makeClientCancelFn
deadlineTime := time.Now().Add(RemoteConnectTimeout)
msh.MakeClientDeadline = &deadlineTime
go msh.NotifyRemoteUpdate()
})
go msh.watchClientDeadlineTime()
session, err := client.NewSession()
if err != nil {
msh.WriteToPtyBuffer("*error, ssh cannot create session: %v\n", err)
}
cproc, initPk, err := shexec.MakeClientProc(makeClientCtx, shexec.SessionWrap{Session: session, StartCmd: MakeServerRunOnlyCommandStr()})
// TODO check if initPk.State is not nil
var mshellVersion string
var hitDeadline bool
msh.WithLock(func() {
msh.MakeClientCancelFn = nil
if time.Now().After(*msh.MakeClientDeadline) {
hitDeadline = true
}
msh.MakeClientDeadline = nil
if initPk == nil {
msh.ErrNoInitPk = true
}
if initPk != nil {
msh.UName = initPk.UName
mshellVersion = initPk.Version
if semver.Compare(mshellVersion, scbase.MShellVersion) < 0 {
// only set NeedsMShellUpgrade if we got an InitPk
msh.NeedsMShellUpgrade = true
}
msh.InitPkShellType = initPk.Shell
}
msh.StateMap.Clear()
// no notify here, because we'll call notify in either case below
})
if err == context.Canceled {
if hitDeadline {
msh.WriteToPtyBuffer("*connect timeout\n")
msh.setErrorStatus(errors.New("connect timeout"))
} else {
msh.WriteToPtyBuffer("*forced disconnection\n")
msh.WithLock(func() {
msh.Status = StatusDisconnected
go msh.NotifyRemoteUpdate()
})
}
return
}
if err == nil && semver.MajorMinor(mshellVersion) != semver.MajorMinor(scbase.MShellVersion) {
err = fmt.Errorf("mshell version is not compatible current=%s remote=%s", scbase.MShellVersion, mshellVersion)
}
if err != nil {
cs := fmt.Sprintf("error: %v\n", err)
os.WriteFile("/Users/oneirocosm/.waveterm-dev/temp.txt", []byte(cs), 0644)
msh.setErrorStatus(err)
msh.WriteToPtyBuffer("*error connecting to remote: %v\n", err)
go msh.tryAutoInstall()
return
}
msh.updateRemoteStateVars(context.Background(), msh.RemoteId, initPk)
msh.WithLock(func() {
msh.ServerProc = cproc
msh.Status = StatusConnected
})
go func() {
exitErr := cproc.Cmd.Wait()
exitCode := shexec.GetExitCode(exitErr)
msh.WithLock(func() {
if msh.Status == StatusConnected || msh.Status == StatusConnecting {
msh.Status = StatusDisconnected
go msh.NotifyRemoteUpdate()
}
})
msh.WriteToPtyBuffer("*disconnected exitcode=%d\n", exitCode)
}()
go msh.ProcessPackets()
msh.initActiveShells()
go msh.NotifyRemoteUpdate()
return
}
func (msh *MShellProc) Launch(interactive bool) {
remoteCopy := msh.GetRemoteCopy()
if remoteCopy.Archived {
msh.WriteToPtyBuffer("cannot launch archived remote\n")
return
}
curStatus := msh.GetStatus()
if curStatus == StatusConnected {
msh.WriteToPtyBuffer("remote is already connected (no action taken)\n")
return
}
if curStatus == StatusConnecting {
msh.WriteToPtyBuffer("remote is already connecting, disconnect before trying to connect again\n")
return
}
sapi, err := shellapi.MakeShellApi(msh.GetShellType())
if err != nil {
msh.WriteToPtyBuffer("*error, %v\n", err)
return
}
istatus := msh.GetInstallStatus()
if istatus == StatusConnecting {
msh.WriteToPtyBuffer("remote is trying to install, cancel install before trying to connect again\n")
return
}
if remoteCopy.SSHOpts.SSHPort != 0 && remoteCopy.SSHOpts.SSHPort != 22 {
msh.WriteToPtyBuffer("connecting to %s (port %d)...\n", remoteCopy.RemoteCanonicalName, remoteCopy.SSHOpts.SSHPort)
} else {
msh.WriteToPtyBuffer("connecting to %s...\n", remoteCopy.RemoteCanonicalName)
}
sshOpts := convertSSHOpts(remoteCopy.SSHOpts)
sshOpts.SSHErrorsToTty = true
if remoteCopy.ConnectMode != sstore.ConnectModeManual && remoteCopy.SSHOpts.SSHPassword == "" && !interactive {
sshOpts.BatchMode = true
}
makeClientCtx, makeClientCancelFn := context.WithCancel(context.Background())
defer makeClientCancelFn()
msh.WithLock(func() {
msh.Err = nil
msh.ErrNoInitPk = false
msh.Status = StatusConnecting
msh.MakeClientCancelFn = makeClientCancelFn
deadlineTime := time.Now().Add(RemoteConnectTimeout)
msh.MakeClientDeadline = &deadlineTime
go msh.NotifyRemoteUpdate()
})
go msh.watchClientDeadlineTime()
var cmdStr string
var cproc *shexec.ClientProc
var initPk *packet.InitPacketType
if sshOpts.SSHHost == "" && remoteCopy.Local {
cmdStr, err = MakeLocalMShellCommandStr(remoteCopy.IsSudo())
if err != nil {
msh.WriteToPtyBuffer("*error, cannot find local mshell binary: %v\n", err)
return
}
ecmd := sshOpts.MakeSSHExecCmd(cmdStr, sapi)
var cmdPty *os.File
cmdPty, err = msh.addControllingTty(ecmd)
if err != nil {
statusErr := fmt.Errorf("cannot attach controlling tty to mshell command: %w", err)
msh.WriteToPtyBuffer("*error, %s\n", statusErr.Error())
msh.setErrorStatus(statusErr)
return
}
defer func() {
if len(ecmd.ExtraFiles) > 0 {
ecmd.ExtraFiles[len(ecmd.ExtraFiles)-1].Close()
}
}()
go msh.RunPtyReadLoop(cmdPty)
if remoteCopy.SSHOpts.SSHPassword != "" {
go msh.WaitAndSendPassword(remoteCopy.SSHOpts.SSHPassword)
}
cproc, initPk, err = shexec.MakeClientProc(makeClientCtx, shexec.CmdWrap{Cmd: ecmd})
} else {
var client *ssh.Client
client, err = sshOpts.ConnectToClient()
es := fmt.Sprintf("err: %v\n", err)
os.WriteFile("/Users/oneirocosm/.waveterm-dev/temp.txt", []byte(es), 0644)
if err != nil {
msh.WriteToPtyBuffer("*error, ssh cannot connect to client: %v\n", err)
}
var session *ssh.Session
session, err = client.NewSession()
if err != nil {
msh.WriteToPtyBuffer("*error, ssh cannot create session: %v\n", err)
}
cproc, initPk, err = shexec.MakeClientProc(makeClientCtx, shexec.SessionWrap{Session: session, StartCmd: MakeServerRunOnlyCommandStr()})
}
// TODO check if initPk.State is not nil
var mshellVersion string
var hitDeadline bool
msh.WithLock(func() {
msh.MakeClientCancelFn = nil
if time.Now().After(*msh.MakeClientDeadline) {
hitDeadline = true
}
msh.MakeClientDeadline = nil
if initPk == nil {
msh.ErrNoInitPk = true
}
if initPk != nil {
msh.UName = initPk.UName
mshellVersion = initPk.Version
if semver.Compare(mshellVersion, scbase.MShellVersion) < 0 {
// only set NeedsMShellUpgrade if we got an InitPk
msh.NeedsMShellUpgrade = true
}
msh.InitPkShellType = initPk.Shell
}
msh.StateMap.Clear()
// no notify here, because we'll call notify in either case below
})
if err == context.Canceled {
if hitDeadline {
msh.WriteToPtyBuffer("*connect timeout\n")
msh.setErrorStatus(errors.New("connect timeout"))
} else {
msh.WriteToPtyBuffer("*forced disconnection\n")
msh.WithLock(func() {
msh.Status = StatusDisconnected
go msh.NotifyRemoteUpdate()
})
}
return
}
if err == nil && semver.MajorMinor(mshellVersion) != semver.MajorMinor(scbase.MShellVersion) {
err = fmt.Errorf("mshell version is not compatible current=%s remote=%s", scbase.MShellVersion, mshellVersion)
}
if err != nil {
msh.setErrorStatus(err)
msh.WriteToPtyBuffer("*error connecting to remote: %v\n", err)
go msh.tryAutoInstall()
return
}
msh.updateRemoteStateVars(context.Background(), msh.RemoteId, initPk)
msh.WithLock(func() {
msh.ServerProc = cproc
msh.Status = StatusConnected
})
go func() {
exitErr := cproc.Cmd.Wait()
exitCode := shexec.GetExitCode(exitErr)
msh.WithLock(func() {
if msh.Status == StatusConnected || msh.Status == StatusConnecting {
msh.Status = StatusDisconnected
go msh.NotifyRemoteUpdate()
}
})
msh.WriteToPtyBuffer("*disconnected exitcode=%d\n", exitCode)
}()
go msh.ProcessPackets()
msh.initActiveShells()
go msh.NotifyRemoteUpdate()
return
}
func (msh *MShellProc) LaunchOld(interactive bool) {
remoteCopy := msh.GetRemoteCopy()
if remoteCopy.Archived {
msh.WriteToPtyBuffer("cannot launch archived remote\n")
@ -1281,7 +1574,7 @@ func (msh *MShellProc) Launch(interactive bool) {
go msh.NotifyRemoteUpdate()
})
go msh.watchClientDeadlineTime()
cproc, initPk, err := shexec.MakeClientProc(makeClientCtx, ecmd)
cproc, initPk, err := shexec.MakeClientProc(makeClientCtx, shexec.CmdWrap{Cmd: ecmd})
// TODO check if initPk.State is not nil
var mshellVersion string
var hitDeadline bool