Skip to content

Commit 00b371a

Browse files
authored
Merge pull request #1647 from smallstep/fix/proxycommand-deadlock-1641
Fix proxycommand hang when server closes before stdin closes
2 parents cd9b4bc + 5b50ed3 commit 00b371a

2 files changed

Lines changed: 70 additions & 9 deletions

File tree

command/ssh/proxycommand.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"net"
77
"os"
88
"strings"
9-
"sync"
109
"time"
1110

1211
"github.com/pkg/errors"
@@ -228,6 +227,10 @@ func getBastion(ctx *cli.Context, user, host string) (*api.SSHBastionResponse, e
228227
}
229228

230229
func proxyDirect(host, port string) error {
230+
return proxyDirectWithIO(host, port, os.Stdin, os.Stdout)
231+
}
232+
233+
func proxyDirectWithIO(host, port string, stdin io.Reader, stdout io.Writer) error {
231234
address := net.JoinHostPort(host, port)
232235
addr, err := net.ResolveTCPAddr("tcp", address)
233236
if err != nil {
@@ -238,22 +241,25 @@ func proxyDirect(host, port string) error {
238241
if err != nil {
239242
return errors.Wrapf(err, "error connecting to %s", address)
240243
}
244+
defer conn.Close()
241245

242-
var wg sync.WaitGroup
243-
wg.Add(1)
246+
// Return as soon as either direction finishes. Waiting for both can
247+
// deadlock when the server closes the connection while stdin stays open.
248+
// See smallstep/cli#1641. Buffered so the slower goroutine never blocks
249+
// sending after we've stopped receiving.
250+
done := make(chan struct{}, 2)
244251
go func() {
245-
io.Copy(conn, os.Stdin)
252+
io.Copy(conn, stdin)
246253
conn.CloseWrite()
247-
wg.Done()
254+
done <- struct{}{}
248255
}()
249-
wg.Add(1)
250256
go func() {
251-
io.Copy(os.Stdout, conn)
257+
io.Copy(stdout, conn)
252258
conn.CloseRead()
253-
wg.Done()
259+
done <- struct{}{}
254260
}()
255261

256-
wg.Wait()
262+
<-done
257263
return nil
258264
}
259265

command/ssh/proxycommand_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package ssh
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// Test_proxyDirectWithIO_serverClosesBeforeStdin reproduces smallstep/cli#1641:
14+
// when the server closes the connection before the client has closed stdin, the
15+
// proxycommand must still return promptly. Previously it would block in
16+
// wg.Wait() forever because the stdin->conn goroutine stayed blocked reading a
17+
// stdin that never reaches EOF (the ssh client keeps it open until the
18+
// proxycommand exits).
19+
func Test_proxyDirectWithIO_serverClosesBeforeStdin(t *testing.T) {
20+
ln, err := net.Listen("tcp", "127.0.0.1:0")
21+
require.NoError(t, err)
22+
defer ln.Close()
23+
24+
// Server sends some data and immediately closes the connection.
25+
go func() {
26+
conn, err := ln.Accept()
27+
if err != nil {
28+
return
29+
}
30+
conn.Write([]byte("hello"))
31+
conn.Close()
32+
}()
33+
34+
host, port, err := net.SplitHostPort(ln.Addr().String())
35+
require.NoError(t, err)
36+
37+
// stdin that never reaches EOF, simulating the ssh client keeping the
38+
// proxycommand's stdin open for the lifetime of the session.
39+
stdinR, stdinW := io.Pipe()
40+
defer stdinW.Close() // write end intentionally left open during the call
41+
42+
var stdout bytes.Buffer
43+
done := make(chan error, 1)
44+
go func() {
45+
done <- proxyDirectWithIO(host, port, stdinR, &stdout)
46+
}()
47+
48+
select {
49+
case err := <-done:
50+
require.NoError(t, err)
51+
require.Equal(t, "hello", stdout.String())
52+
case <-time.After(5 * time.Second):
53+
t.Fatal("proxyDirectWithIO did not return after the server closed the connection")
54+
}
55+
}

0 commit comments

Comments
 (0)