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
51 changes: 51 additions & 0 deletions pkg/remote/conncontroller/conncontroller.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type SSHConn struct {
Status string
ConnHealthStatus string
WshEnabled *atomic.Bool
WshEnsuring *atomic.Bool
Opts *remote.SSHOpts
Client *ssh.Client
DomainSockName string // if "", then no domain socket
Expand Down Expand Up @@ -269,13 +270,28 @@ func (conn *SSHConn) GetName() string {

func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error {
conn.Infof(ctx, "running OpenDomainSocketListener...\n")
var existingListener net.Listener
var existingSockName string
allowed := WithLockRtn(conn, func() bool {
// If it's already set up, allow callers to reuse it even if the conn is already connected.
if conn.DomainSockListener != nil && conn.DomainSockName != "" {
existingListener = conn.DomainSockListener
existingSockName = conn.DomainSockName
return true
}
return conn.Status == Status_Connecting
})
if !allowed {
return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus())
}
if existingListener != nil && existingSockName != "" {
conn.Infof(ctx, "domain socket already active (%s)\n", existingSockName)
return nil
}
client := conn.GetClient()
if client == nil {
return fmt.Errorf("cannot open domain socket for %q: ssh client is not connected", conn.GetName())
}
randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness
if err != nil {
return fmt.Errorf("error generating random string: %w", err)
Expand Down Expand Up @@ -1075,6 +1091,7 @@ func getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn {
Status: Status_Init,
ConnHealthStatus: ConnHealthStatus_Good,
WshEnabled: &atomic.Bool{},
WshEnsuring: &atomic.Bool{},
Opts: opts,
}
clientControllerMap[*opts] = rtn
Expand Down Expand Up @@ -1125,6 +1142,40 @@ func EnsureConnection(ctx context.Context, connName string) error {
connStatus := conn.DeriveConnStatus()
switch connStatus.Status {
case Status_Connected:
// If wsh is enabled for this connection, ensure the connserver route exists.
// This prevents "no route for \"conn:...\"" errors when using remote file browsing after a
// connserver restart/termination.
enableWsh, _ := conn.getConnWshSettings()
if enableWsh {
routeId := wshutil.MakeConnectionRouteId(conn.GetName())
fastCtx, cancelFn := context.WithTimeout(ctx, 75*time.Millisecond)
fastErr := wshutil.DefaultRouter.WaitForRegister(fastCtx, routeId)
cancelFn()
if fastErr != nil {
// Avoid a thundering herd when multiple blocks ensure concurrently.
if conn.WshEnsuring != nil && !conn.WshEnsuring.CompareAndSwap(false, true) {
waitCtx, cancelWait := context.WithTimeout(ctx, 5*time.Second)
defer cancelWait()
if err := wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId); err != nil {
return fmt.Errorf("waiting for concurrent wsh setup for %q: %w", conn.GetName(), err)
}
return nil
}
if conn.WshEnsuring != nil {
defer conn.WshEnsuring.Store(false)
}
wshResult := conn.tryEnableWsh(ctx, conn.GetName())
conn.persistWshInstalled(ctx, wshResult)
if !wshResult.WshEnabled {
if wshResult.WshError != nil {
return wshResult.WshError
}
if wshResult.NoWshReason != "" {
return fmt.Errorf("wsh unavailable for %q: %s", conn.GetName(), wshResult.NoWshReason)
}
}
}
}
return nil
case Status_Connecting:
return conn.WaitForConnect(ctx)
Expand Down
110 changes: 110 additions & 0 deletions pkg/shellexec/shellexec.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
"github.com/wavetermdev/waveterm/pkg/util/pamparse"
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
Expand Down Expand Up @@ -107,6 +108,115 @@ func ExitCodeFromWaitErr(err error) int {

}

// escapeForPosixDoubleQuotes escapes special characters for use inside POSIX double quotes.
// It escapes: \\, ", $, and ` to be safe inside "$HOME<rest>" where <rest> should be treated literally.
func escapeForPosixDoubleQuotes(s string) string {
// Conservative escaping for the subset of chars that are special inside double quotes.
// This is used for "$HOME<rest>" where <rest> should be treated literally.
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\', '"', '$', '`':
b.WriteByte('\\')
b.WriteByte(s[i])
default:
b.WriteByte(s[i])
}
}
return b.String()
}

// posixCwdExpr returns a POSIX shell expression for the given current working directory.
// It handles tilde (~) expansion by using $HOME for paths starting with ~/, and quotes other paths appropriately.
func posixCwdExpr(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
if cwd == "~" {
return "~"
}
if strings.HasPrefix(cwd, "~/") {
// "~" must be expanded on the target machine. Use $HOME so we can still quote paths with spaces safely.
rest := cwd[1:] // includes leading "/"
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
}
return utilfn.ShellQuote(cwd, false, -1)
}

// posixCwdExprNoWshRemote returns a POSIX shell expression for the given current working directory on a remote SSH connection.
// It uses ~user syntax for tilde paths when an SSH username is provided, avoiding dependency on $HOME on the remote shell.
func posixCwdExprNoWshRemote(cwd string, sshUser string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
sshUser = strings.TrimSpace(sshUser)
if sshUser == "" {
return posixCwdExpr(cwd)
}
if cwd == "~" {
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
return "~" + sshUser
}
if cwd == "~/" {
return "~" + sshUser + "/"
}
if strings.HasPrefix(cwd, "~/") {
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
rest := cwd[1:] // includes leading "/"
if strings.ContainsAny(rest, " \t\n\r'\"`$&|;<>()\\*[]?!") {
// Quote the rest to handle spaces and special characters
return "~" + sshUser + "'" + strings.ReplaceAll(rest, "'", "''") + "'"
}
return "~" + sshUser + rest
}
return posixCwdExpr(cwd)
}

// fishCwdExpr returns a Fish shell expression for the given current working directory.
// Fish requires $HOME for tilde paths in double-quoted strings to handle spaces safely.
func fishCwdExpr(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
if cwd == "~" {
return "~"
}
if strings.HasPrefix(cwd, "~/") {
// Fish does not expand ~ inside double quotes, use $HOME instead
rest := cwd[1:] // includes leading "/"
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
}
return utilfn.ShellQuote(cwd, false, -1)
}

// pwshCwdExpr returns a PowerShell expression for the given current working directory.
// PowerShell uses ~ correctly by default; paths with spaces or special characters are wrapped in quotes.
func pwshCwdExpr(cwd string) string {
cwd = strings.TrimSpace(cwd)
if cwd == "" {
return ""
}
if cwd == "~" {
return "~"
}
if strings.HasPrefix(cwd, "~/") {
rest := cwd[1:]
if strings.ContainsAny(rest, " \"'`$()[]{}") {
// Use single quotes for the path portion to escape special characters
return "~'" + strings.ReplaceAll(rest, "'", "''") + "'"
}
return cwd
}
if strings.ContainsAny(cwd, " \"'`$()[]{}") {
return "'" + strings.ReplaceAll(cwd, "'", "''") + "'"
}
return cwd
}

func checkCwd(cwd string) error {
if cwd == "" {
return fmt.Errorf("cwd is empty")
Expand Down
Loading