Skip to content

Commit 9d05f5f

Browse files
committed
fix: prevent "no route for conn:..." errors and add shell path expansion
- Auto-recover wsh routes after connserver restart - Allow domain socket listener reuse when already established - Add WshEnsuring flag to prevent thundering herd - Add shell-specific path expansion (posix, fish, pwsh) with tilde (~) support - Add escapeForPosixDoubleQuotes for safe path quoting - Add unit tests for path expansion logic
1 parent 02b9e5f commit 9d05f5f

File tree

3 files changed

+354
-0
lines changed

3 files changed

+354
-0
lines changed

pkg/remote/conncontroller/conncontroller.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type SSHConn struct {
8080
Status string
8181
ConnHealthStatus string
8282
WshEnabled *atomic.Bool
83+
WshEnsuring *atomic.Bool
8384
Opts *remote.SSHOpts
8485
Client *ssh.Client
8586
DomainSockName string // if "", then no domain socket
@@ -269,13 +270,28 @@ func (conn *SSHConn) GetName() string {
269270

270271
func (conn *SSHConn) OpenDomainSocketListener(ctx context.Context) error {
271272
conn.Infof(ctx, "running OpenDomainSocketListener...\n")
273+
var existingListener net.Listener
274+
var existingSockName string
272275
allowed := WithLockRtn(conn, func() bool {
276+
// If it's already set up, allow callers to reuse it even if the conn is already connected.
277+
if conn.DomainSockListener != nil && conn.DomainSockName != "" {
278+
existingListener = conn.DomainSockListener
279+
existingSockName = conn.DomainSockName
280+
return true
281+
}
273282
return conn.Status == Status_Connecting
274283
})
275284
if !allowed {
276285
return fmt.Errorf("cannot open domain socket for %q when status is %q", conn.GetName(), conn.GetStatus())
277286
}
287+
if existingListener != nil && existingSockName != "" {
288+
conn.Infof(ctx, "domain socket already active (%s)\n", existingSockName)
289+
return nil
290+
}
278291
client := conn.GetClient()
292+
if client == nil {
293+
return fmt.Errorf("cannot open domain socket for %q: ssh client is not connected", conn.GetName())
294+
}
279295
randStr, err := utilfn.RandomHexString(16) // 64-bits of randomness
280296
if err != nil {
281297
return fmt.Errorf("error generating random string: %w", err)
@@ -1075,6 +1091,7 @@ func getConnInternal(opts *remote.SSHOpts, createIfNotExists bool) *SSHConn {
10751091
Status: Status_Init,
10761092
ConnHealthStatus: ConnHealthStatus_Good,
10771093
WshEnabled: &atomic.Bool{},
1094+
WshEnsuring: &atomic.Bool{},
10781095
Opts: opts,
10791096
}
10801097
clientControllerMap[*opts] = rtn
@@ -1125,6 +1142,40 @@ func EnsureConnection(ctx context.Context, connName string) error {
11251142
connStatus := conn.DeriveConnStatus()
11261143
switch connStatus.Status {
11271144
case Status_Connected:
1145+
// If wsh is enabled for this connection, ensure the connserver route exists.
1146+
// This prevents "no route for \"conn:...\"" errors when using remote file browsing after a
1147+
// connserver restart/termination.
1148+
enableWsh, _ := conn.getConnWshSettings()
1149+
if enableWsh {
1150+
routeId := wshutil.MakeConnectionRouteId(conn.GetName())
1151+
fastCtx, cancelFn := context.WithTimeout(ctx, 75*time.Millisecond)
1152+
fastErr := wshutil.DefaultRouter.WaitForRegister(fastCtx, routeId)
1153+
cancelFn()
1154+
if fastErr != nil {
1155+
// Avoid a thundering herd when multiple blocks ensure concurrently.
1156+
if conn.WshEnsuring != nil && !conn.WshEnsuring.CompareAndSwap(false, true) {
1157+
waitCtx, cancelWait := context.WithTimeout(ctx, 5*time.Second)
1158+
defer cancelWait()
1159+
if err := wshutil.DefaultRouter.WaitForRegister(waitCtx, routeId); err != nil {
1160+
return fmt.Errorf("waiting for concurrent wsh setup for %q: %w", conn.GetName(), err)
1161+
}
1162+
return nil
1163+
}
1164+
if conn.WshEnsuring != nil {
1165+
defer conn.WshEnsuring.Store(false)
1166+
}
1167+
wshResult := conn.tryEnableWsh(ctx, conn.GetName())
1168+
conn.persistWshInstalled(ctx, wshResult)
1169+
if !wshResult.WshEnabled {
1170+
if wshResult.WshError != nil {
1171+
return wshResult.WshError
1172+
}
1173+
if wshResult.NoWshReason != "" {
1174+
return fmt.Errorf("wsh unavailable for %q: %s", conn.GetName(), wshResult.NoWshReason)
1175+
}
1176+
}
1177+
}
1178+
}
11281179
return nil
11291180
case Status_Connecting:
11301181
return conn.WaitForConnect(ctx)

pkg/shellexec/shellexec.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
2727
"github.com/wavetermdev/waveterm/pkg/util/pamparse"
2828
"github.com/wavetermdev/waveterm/pkg/util/shellutil"
29+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
2930
"github.com/wavetermdev/waveterm/pkg/wavebase"
3031
"github.com/wavetermdev/waveterm/pkg/waveobj"
3132
"github.com/wavetermdev/waveterm/pkg/wshrpc"
@@ -107,6 +108,115 @@ func ExitCodeFromWaitErr(err error) int {
107108

108109
}
109110

111+
// escapeForPosixDoubleQuotes escapes special characters for use inside POSIX double quotes.
112+
// It escapes: \\, ", $, and ` to be safe inside "$HOME<rest>" where <rest> should be treated literally.
113+
func escapeForPosixDoubleQuotes(s string) string {
114+
// Conservative escaping for the subset of chars that are special inside double quotes.
115+
// This is used for "$HOME<rest>" where <rest> should be treated literally.
116+
var b strings.Builder
117+
b.Grow(len(s))
118+
for i := 0; i < len(s); i++ {
119+
switch s[i] {
120+
case '\\', '"', '$', '`':
121+
b.WriteByte('\\')
122+
b.WriteByte(s[i])
123+
default:
124+
b.WriteByte(s[i])
125+
}
126+
}
127+
return b.String()
128+
}
129+
130+
// posixCwdExpr returns a POSIX shell expression for the given current working directory.
131+
// It handles tilde (~) expansion by using $HOME for paths starting with ~/, and quotes other paths appropriately.
132+
func posixCwdExpr(cwd string) string {
133+
cwd = strings.TrimSpace(cwd)
134+
if cwd == "" {
135+
return ""
136+
}
137+
if cwd == "~" {
138+
return "~"
139+
}
140+
if strings.HasPrefix(cwd, "~/") {
141+
// "~" must be expanded on the target machine. Use $HOME so we can still quote paths with spaces safely.
142+
rest := cwd[1:] // includes leading "/"
143+
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
144+
}
145+
return utilfn.ShellQuote(cwd, false, -1)
146+
}
147+
148+
// posixCwdExprNoWshRemote returns a POSIX shell expression for the given current working directory on a remote SSH connection.
149+
// It uses ~user syntax for tilde paths when an SSH username is provided, avoiding dependency on $HOME on the remote shell.
150+
func posixCwdExprNoWshRemote(cwd string, sshUser string) string {
151+
cwd = strings.TrimSpace(cwd)
152+
if cwd == "" {
153+
return ""
154+
}
155+
sshUser = strings.TrimSpace(sshUser)
156+
if sshUser == "" {
157+
return posixCwdExpr(cwd)
158+
}
159+
if cwd == "~" {
160+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
161+
return "~" + sshUser
162+
}
163+
if cwd == "~/" {
164+
return "~" + sshUser + "/"
165+
}
166+
if strings.HasPrefix(cwd, "~/") {
167+
// Prefer ~user so we don't depend on $HOME being correct on the remote shell.
168+
rest := cwd[1:] // includes leading "/"
169+
if strings.ContainsAny(rest, " \t\n\r'\"`$&|;<>()\\*[]?!") {
170+
// Quote the rest to handle spaces and special characters
171+
return "~" + sshUser + "'" + strings.ReplaceAll(rest, "'", "''") + "'"
172+
}
173+
return "~" + sshUser + rest
174+
}
175+
return posixCwdExpr(cwd)
176+
}
177+
178+
// fishCwdExpr returns a Fish shell expression for the given current working directory.
179+
// Fish requires $HOME for tilde paths in double-quoted strings to handle spaces safely.
180+
func fishCwdExpr(cwd string) string {
181+
cwd = strings.TrimSpace(cwd)
182+
if cwd == "" {
183+
return ""
184+
}
185+
if cwd == "~" {
186+
return "~"
187+
}
188+
if strings.HasPrefix(cwd, "~/") {
189+
// Fish does not expand ~ inside double quotes, use $HOME instead
190+
rest := cwd[1:] // includes leading "/"
191+
return fmt.Sprintf("\"$HOME%s\"", escapeForPosixDoubleQuotes(rest))
192+
}
193+
return utilfn.ShellQuote(cwd, false, -1)
194+
}
195+
196+
// pwshCwdExpr returns a PowerShell expression for the given current working directory.
197+
// PowerShell uses ~ correctly by default; paths with spaces or special characters are wrapped in quotes.
198+
func pwshCwdExpr(cwd string) string {
199+
cwd = strings.TrimSpace(cwd)
200+
if cwd == "" {
201+
return ""
202+
}
203+
if cwd == "~" {
204+
return "~"
205+
}
206+
if strings.HasPrefix(cwd, "~/") {
207+
rest := cwd[1:]
208+
if strings.ContainsAny(rest, " \"'`$()[]{}") {
209+
// Use single quotes for the path portion to escape special characters
210+
return "~'" + strings.ReplaceAll(rest, "'", "''") + "'"
211+
}
212+
return cwd
213+
}
214+
if strings.ContainsAny(cwd, " \"'`$()[]{}") {
215+
return "'" + strings.ReplaceAll(cwd, "'", "''") + "'"
216+
}
217+
return cwd
218+
}
219+
110220
func checkCwd(cwd string) error {
111221
if cwd == "" {
112222
return fmt.Errorf("cwd is empty")

0 commit comments

Comments
 (0)