Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/connections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ At the moment, we are capable of parsing any SSH config file that does not conta
|PreferredAuthentications| (partial) Specifies the order the client should attempt to authenticate in. It is partially implemented as it does not support `gssapi-with-mic` or `hostbased` authentication. The default is `publickey,keyboard-interactive,password`|
|AddKeysToAgent| (partial) This option will automatically add keys and their corresponding passphrase to your running ssh agent if it is enabled. It is partially supported as it can only accept `yes` and `no` as valid inputs. Other inputs such as `confirm` or a time interval will behave the same as `no`. The default value is `no`.|
|IdentityAgent| Specifies the Unix Domain Socket used to communicate with the SSH Agent. This is used to overwrite the SSH_AUTH_SOCK identity agent.|
|ForwardAgent| (partial) Specifies whether the connection to the ssh agent will be forwarded to the remote machine. It is partially supported as it can only accept `yes` and `no` as valid inputs. Other inputs such as an explicit socket path or environment variable name will behave the same as `no`. The default value is `no`.|
|IdentitiesOnly| Specifies that only the specified authentication identity files should be used. This is either the default files or the ones specified with the IdentityFile keyword. It can accept `yes` or `no`. The default value is `no`.|
|ProxyJump| Specifies one or more jump proxies in a comma separated list. Each will be visited sequentially using TCP forwarding before connecting to the desired connection (also using TCP forwarding). It can be set to `none` to disable the feature.|
|UserKnownHostsFile| Provides the location of one or more user host key database files for recording trusted remote connections. The filenames are entered in the same string and separated by whitespace. The default value is `"~/.ssh/known_hosts ~/.ssh/known_hosts2"`.|
Expand Down Expand Up @@ -155,6 +156,7 @@ In addition to the regular ssh config file, wave also has its own config file to
| ssh:preferredauthentications | A list of strings indicating an ordering of different types of authentications. Each authentication type will be tried in order. This supports `"publickey"`, `"keyboard-interactive"`, and `"password"` as valid types. Other types of authentication are not handled and will be skipped. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:addkeystoagent | A boolean indicating if the keys used for a connection should be added to the ssh agent. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:identityagent | A string giving the path to the unix domain socket of the identity agent. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:forwardagent | A boolean indicating if the connection to the ssh agent will be forwarded to the remote machine. Can be used to override the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:proxyjump | A list of strings specifying the names of hosts that must be successively visited with tcp forwarding to establish a connection. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:userknownhostsfile | A list containing the paths of any user host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
| ssh:globalknownhostsfile | A list containing the paths of any global host key database files used to keep track of authorized connections. Can be used to overwrite the value in `~/.ssh/config` or to set it if the ssh config is being ignored.|
Expand Down
1 change: 1 addition & 0 deletions frontend/types/gotypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,7 @@ declare global {
"ssh:preferredauthentications"?: string[];
"ssh:addkeystoagent"?: boolean;
"ssh:identityagent"?: string;
"ssh:forwardagent"?: boolean;
"ssh:identitiesonly"?: boolean;
"ssh:proxyjump"?: string[];
"ssh:userknownhostsfile"?: string[];
Expand Down
10 changes: 9 additions & 1 deletion pkg/remote/conncontroller/conncontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type SSHConn struct {
WshEnabled *atomic.Bool
Opts *remote.SSHOpts
Client *ssh.Client
ForwardAgent bool
DomainSockName string // if "", then no domain socket
DomainSockListener net.Listener
ConnController *ssh.Session
Expand Down Expand Up @@ -695,6 +696,12 @@ func (conn *SSHConn) GetClient() *ssh.Client {
return conn.Client
}

func (conn *SSHConn) GetForwardAgent() bool {
conn.lock.Lock()
defer conn.lock.Unlock()
return conn.ForwardAgent
}

func (conn *SSHConn) GetMonitor() *ConnMonitor {
conn.lock.Lock()
defer conn.lock.Unlock()
Expand Down Expand Up @@ -952,7 +959,7 @@ func (conn *SSHConn) persistWshInstalled(ctx context.Context, result WshCheckRes
// returns (connect-error)
func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wconfig.ConnKeywords) error {
conn.Infof(ctx, "connectInternal %s\n", conn.GetName())
client, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0, connFlags)
client, forwardAgent, _, err := remote.ConnectToClient(ctx, conn.Opts, nil, 0, connFlags)
if err != nil {
conn.Infof(ctx, "ERROR ConnectToClient: %s\n", remote.SimpleMessageFromPossibleConnectionError(err))
log.Printf("error: failed to connect to client %s: %s\n", conn.GetName(), err)
Expand All @@ -964,6 +971,7 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wconfig.Con
conn.Monitor = nil
}
conn.Client = client
conn.ForwardAgent = forwardAgent
conn.ConnHealthStatus = ConnHealthStatus_Good
conn.Monitor = MakeConnMonitor(conn, client)
})
Expand Down
108 changes: 98 additions & 10 deletions pkg/remote/sshclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"math"
"net"
Expand Down Expand Up @@ -838,6 +839,67 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor
}, nil
}

// unlike x/crypto's agent.ForwardToAgent, this dials the agent freshly for
// each forwarded channel (matching openssh behavior) so forwarding survives
// agent restarts (e.g. 1password relock) and works independently of the
// auth-time agent connection (which IdentitiesOnly disables)
func setupAgentForwarding(client *ssh.Client, agentPath string) error {
testConn, err := dialIdentityAgent(agentPath)
if err != nil {
return err
}
testConn.Close()
channels := client.HandleChannelOpen("auth-agent@openssh.com")
if channels == nil {
return fmt.Errorf("agent forwarding already set up on this connection")
}
go func() {
defer func() {
panichandler.PanicHandler("sshclient:agent-forwarding", recover())
}()
for ch := range channels {
channel, reqs, err := ch.Accept()
if err != nil {
continue
}
go ssh.DiscardRequests(reqs)
go forwardAgentChannel(channel, agentPath)
}
}()
return nil
}

func forwardAgentChannel(channel ssh.Channel, agentPath string) {
defer func() {
panichandler.PanicHandler("sshclient:agent-forward-channel", recover())
}()
defer channel.Close()
agentConn, err := dialIdentityAgent(agentPath)
if err != nil {
log.Printf("agent forwarding: failed to dial agent %q: %v", agentPath, err)
return
}
defer agentConn.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
io.Copy(agentConn, channel)
// half-close so the agent sees EOF; windows pipes may not support it
if cw, ok := agentConn.(interface{ CloseWrite() error }); ok {
cw.CloseWrite()
} else {
agentConn.Close()
}
}()
go func() {
defer wg.Done()
io.Copy(channel, agentConn)
channel.CloseWrite()
}()
wg.Wait()
}

func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.ClientConfig, currentClient *ssh.Client) (*ssh.Client, error) {
var clientConn net.Conn
var err error
Expand Down Expand Up @@ -868,15 +930,18 @@ func connectInternal(ctx context.Context, networkAddr string, clientConfig *ssh.
return ssh.NewClient(c, chans, reqs), nil
}

func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, int32, error) {
// the returned bool indicates whether ssh agent forwarding was successfully
// set up on the client; callers must still request forwarding per session
// via agent.RequestAgentForwarding
func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.Client, jumpNum int32, connFlags *wconfig.ConnKeywords) (*ssh.Client, bool, int32, error) {
blocklogger.Infof(connCtx, "[conndebug] ConnectToClient %s (jump:%d)...\n", opts.String(), jumpNum)
debugInfo := &ConnectionDebugInfo{
CurrentClient: currentClient,
NextOpts: opts,
JumpNum: jumpNum,
}
if jumpNum > SshProxyJumpMaxDepth {
return nil, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.Errorf(ConnErrCode_ProxyDepth, "ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)}
return nil, false, jumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.Errorf(ConnErrCode_ProxyDepth, "ProxyJump %d exceeds Wave's max depth of %d", jumpNum, SshProxyJumpMaxDepth)}
}

rawName := opts.String()
Expand All @@ -892,14 +957,14 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.
sshConfigKeywords, err = findSshDefaults(opts.SSHHost)
if err != nil {
err = utilds.MakeCodedError(ConnErrCode_ConfigDefault, fmt.Errorf("cannot determine default config keywords: %w", err))
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
return nil, false, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
} else {
var err error
sshConfigKeywords, err = findSshConfigKeywords(opts.SSHHost)
if err != nil {
err = utilds.MakeCodedError(ConnErrCode_ConfigParse, fmt.Errorf("cannot determine config keywords: %w", err))
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
return nil, false, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
}

Expand Down Expand Up @@ -930,7 +995,7 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.
for _, proxyName := range sshKeywords.SshProxyJump {
proxyOpts, err := ParseOpts(proxyName)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_ProxyParse, err)}
return nil, false, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: utilds.MakeCodedError(ConnErrCode_ProxyParse, err)}
}

// ensure no overflow (this will likely never happen)
Expand All @@ -939,23 +1004,36 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh.
}

// do not apply supplied keywords to proxies - ssh config must be used for that
debugInfo.CurrentClient, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum, &wconfig.ConnKeywords{})
// agent forwarding is only set up on the final hop, so the proxy's forwarding flag is ignored
debugInfo.CurrentClient, _, jumpNum, err = ConnectToClient(connCtx, proxyOpts, debugInfo.CurrentClient, jumpNum, &wconfig.ConnKeywords{})
if err != nil {
// do not add a context on a recursive call
// (this can cause a recursive nested context that's arbitrarily deep)
return nil, jumpNum, err
return nil, false, jumpNum, err
}
}
clientConfig, err := createClientConfig(connCtx, sshKeywords, debugInfo)
if err != nil {
return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
return nil, false, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
networkAddr := utilfn.SafeDeref(sshKeywords.SshHostName) + ":" + utilfn.SafeDeref(sshKeywords.SshPort)
client, err := connectInternal(connCtx, networkAddr, clientConfig, debugInfo.CurrentClient)
if err != nil {
return client, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
return client, false, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err}
}
forwardAgent := false
if utilfn.SafeDeref(sshKeywords.SshForwardAgent) {
agentPath := strings.TrimSpace(utilfn.SafeDeref(sshKeywords.SshIdentityAgent))
if agentPath == "" {
blocklogger.Infof(connCtx, "[conndebug] ForwardAgent requested but no ssh agent socket found, skipping\n")
} else if err := setupAgentForwarding(client, agentPath); err != nil {
blocklogger.Infof(connCtx, "[conndebug] error setting up agent forwarding: %v\n", err)
} else {
blocklogger.Infof(connCtx, "[conndebug] ssh agent forwarding enabled\n")
forwardAgent = true
}
}
return client, debugInfo.JumpNum, nil
return client, forwardAgent, debugInfo.JumpNum, nil
}

// note that a `var == "yes"` will default to false
Expand Down Expand Up @@ -1048,6 +1126,12 @@ func findSshConfigKeywords(hostPattern string) (connKeywords *wconfig.ConnKeywor
}
sshKeywords.SshAddKeysToAgent = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(addKeysToAgentRaw)) == "yes")

forwardAgentRaw, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "ForwardAgent")
if err != nil {
return nil, err
}
sshKeywords.SshForwardAgent = utilfn.Ptr(strings.ToLower(trimquotes.TryTrimQuotes(forwardAgentRaw)) == "yes")

identitiesOnly, err := WaveSshConfigUserSettings().GetStrict(hostPattern, "IdentitiesOnly")
if err != nil {
return nil, err
Expand Down Expand Up @@ -1125,6 +1209,7 @@ func findSshDefaults(hostPattern string) (connKeywords *wconfig.ConnKeywords, ou
sshKeywords.SshKbdInteractiveAuthentication = utilfn.Ptr(true)
sshKeywords.SshPreferredAuthentications = strings.Split(ssh_config.Default("PreferredAuthentications"), ",")
sshKeywords.SshAddKeysToAgent = utilfn.Ptr(false)
sshKeywords.SshForwardAgent = utilfn.Ptr(false)
sshKeywords.SshIdentitiesOnly = utilfn.Ptr(false)
sshKeywords.SshIdentityAgent = utilfn.Ptr(ssh_config.Default("IdentityAgent"))
sshKeywords.SshProxyJump = []string{}
Expand Down Expand Up @@ -1191,6 +1276,9 @@ func mergeKeywords(oldKeywords *wconfig.ConnKeywords, newKeywords *wconfig.ConnK
if newKeywords.SshIdentityAgent != nil {
outKeywords.SshIdentityAgent = newKeywords.SshIdentityAgent
}
if newKeywords.SshForwardAgent != nil {
outKeywords.SshForwardAgent = newKeywords.SshForwardAgent
}
if newKeywords.SshIdentitiesOnly != nil {
outKeywords.SshIdentitiesOnly = newKeywords.SshIdentitiesOnly
}
Expand Down
14 changes: 14 additions & 0 deletions pkg/shellexec/shellexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
"github.com/wavetermdev/waveterm/pkg/wslconn"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
)

const DefaultGracefulKillWait = 400 * time.Millisecond
Expand Down Expand Up @@ -291,6 +293,16 @@ func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr st
return &ShellProc{Cmd: cmdWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil
}

// forwarding failures are non-fatal; the shell still starts, just without the agent socket
func requestAgentForwardingIfEnabled(ctx context.Context, conn *conncontroller.SSHConn, session *ssh.Session) {
if !conn.GetForwardAgent() {
return
}
if err := agent.RequestAgentForwarding(session); err != nil {
conn.Infof(ctx, "error requesting agent forwarding: %v\n", err)
}
}

func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) {
client := conn.GetClient()
conn.Infof(ctx, "SSH-NEWSESSION (StartRemoteShellProcNoWsh)")
Expand Down Expand Up @@ -325,6 +337,7 @@ func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, c
session.Stderr = remoteStdoutWrite

session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
requestAgentForwardingIfEnabled(ctx, conn, session)
sessionWrap := MakeSessionWrap(session, "", pipePty)
err = session.Shell()
if err != nil {
Expand Down Expand Up @@ -460,6 +473,7 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize
}
shellutil.AddTokenSwapEntry(cmdOpts.SwapToken)
session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil)
requestAgentForwardingIfEnabled(logCtx, conn, session)
sessionWrap := MakeSessionWrap(session, cmdCombined, pipePty)
err = sessionWrap.Start()
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/wconfig/settingsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ type ConnKeywords struct {
SshPreferredAuthentications []string `json:"ssh:preferredauthentications,omitempty"`
SshAddKeysToAgent *bool `json:"ssh:addkeystoagent,omitempty"`
SshIdentityAgent *string `json:"ssh:identityagent,omitempty"`
SshForwardAgent *bool `json:"ssh:forwardagent,omitempty"`
SshIdentitiesOnly *bool `json:"ssh:identitiesonly,omitempty"`
SshProxyJump []string `json:"ssh:proxyjump,omitempty"`
SshUserKnownHostsFile []string `json:"ssh:userknownhostsfile,omitempty"`
Expand Down
3 changes: 3 additions & 0 deletions schema/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
"ssh:identityagent": {
"type": "string"
},
"ssh:forwardagent": {
"type": "boolean"
},
"ssh:identitiesonly": {
"type": "boolean"
},
Expand Down