forked from daytonaio/daytona
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathteardown_test.go
More file actions
371 lines (328 loc) · 11.2 KB
/
Copy pathteardown_test.go
File metadata and controls
371 lines (328 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
// Copyright Daytona Platforms Inc.
// SPDX-License-Identifier: AGPL-3.0
package main
import (
"crypto/rand"
"crypto/rsa"
"io"
"net"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// testSigner generates a throwaway RSA signer for in-memory SSH tests.
func testSigner(t *testing.T) ssh.Signer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("generate RSA key: %v", err)
}
signer, err := ssh.NewSignerFromKey(key)
if err != nil {
t.Fatalf("make signer: %v", err)
}
return signer
}
// channelPair holds both ends of an in-memory SSH channel.
type channelPair struct {
// server is the channel as seen by the SSH server (Accept()-returned).
server ssh.Channel
// client is the channel as seen by the SSH client (OpenChannel()-returned).
client ssh.Channel
// connClosed is closed when the client connection dies — the signal the
// production watcher goroutine selects on to hard-close the upstream channel.
connClosed chan struct{}
// closeConns tears down both sides of the underlying TCP connection.
closeConns func()
// closeClient closes only the client-side TCP connection, simulating an
// abrupt SSH client disconnect (SIGKILL / network drop). This closes
// connClosed.
closeClient func()
}
// newChannelPair creates an SSH connection backed by a loopback TCP socket and
// returns a connected channel pair. A loopback socket is used instead of
// net.Pipe() because net.Pipe() is synchronous with no kernel buffer: when both
// sides of an SSH handshake write their version string simultaneously, both block
// indefinitely. TCP's OS send buffer absorbs the small version string so neither
// side stalls.
func newChannelPair(t *testing.T) *channelPair {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
accepted := make(chan net.Conn, 1)
go func() {
conn, err := ln.Accept()
ln.Close() //nolint:errcheck — only one connection needed
if err == nil {
accepted <- conn
}
}()
cliConn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatalf("dial: %v", err)
}
var srvConn net.Conn
select {
case srvConn = <-accepted:
case <-time.After(5 * time.Second):
cliConn.Close() //nolint:errcheck
t.Fatal("accept timeout")
}
serverCfg := &ssh.ServerConfig{NoClientAuth: true}
serverCfg.AddHostKey(testSigner(t))
serverChanCh := make(chan ssh.Channel, 1)
serverConnCh := make(chan *ssh.ServerConn, 1)
go func() {
sconn, chans, reqs, err := ssh.NewServerConn(srvConn, serverCfg)
if err != nil {
return
}
serverConnCh <- sconn
go ssh.DiscardRequests(reqs)
newCh, ok := <-chans
if !ok {
return
}
ch, reqs, err := newCh.Accept()
if err != nil {
return
}
go ssh.DiscardRequests(reqs)
serverChanCh <- ch
for range chans {
} // drain remaining channels
}()
clientCfg := &ssh.ClientConfig{
User: "test",
Auth: []ssh.AuthMethod{ssh.Password("")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
cc, cliChans, cliReqs, err := ssh.NewClientConn(cliConn, "", clientCfg)
if err != nil {
t.Fatalf("ssh client connect: %v", err)
}
go ssh.DiscardRequests(cliReqs)
go func() {
for range cliChans {
}
}()
clientCh, chReqs, err := cc.OpenChannel("session", nil)
if err != nil {
t.Fatalf("open channel: %v", err)
}
go ssh.DiscardRequests(chReqs)
var serverCh ssh.Channel
select {
case serverCh = <-serverChanCh:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for server to accept channel")
}
// Mirror handleConnection: one Wait() goroutine per connection closes
// connClosed for all per-channel watchers.
connClosed := make(chan struct{})
go func() {
serverConn := <-serverConnCh
_ = serverConn.Wait()
close(connClosed)
}()
return &channelPair{
server: serverCh,
client: clientCh,
connClosed: connClosed,
closeConns: func() {
cliConn.Close() //nolint:errcheck
srvConn.Close() //nolint:errcheck
},
closeClient: func() {
cliConn.Close() //nolint:errcheck
},
}
}
// TestClientDisconnectTeardownPropagatesUpstream is the regression test for the stale
// SSH keepalive bug (https://github.com/daytonaio/daytona/issues/4805).
//
// Before the fix, killing the SSH client (SIGKILL / network drop) left the reverse
// io.Copy (runner→client) blocked indefinitely: nothing closed runnerChannel, so the
// keepalive goroutine's context was never cancelled, lastActivityAt kept refreshing every
// 45 s, and auto-stop never triggered.
//
// The fix watches the client connection itself: when it dies, runnerChannel.Close()
// (MSG_CHANNEL_CLOSE) forces the runner to send its own MSG_CHANNEL_CLOSE in reply,
// unblocking the reverse io.Copy. A clean stdin EOF only half-closes (CloseWrite),
// preserving remaining output and exit-status.
func TestClientDisconnectTeardownPropagatesUpstream(t *testing.T) {
t.Parallel()
// clientPair simulates the gateway receiving a channel from the external SSH client.
// clientPair.server == clientChannel in handleChannel (gateway's server-side view).
// clientPair.closeClient() simulates SIGKILL: drops the TCP connection so
// clientPair.connClosed fires, after which runnerChannel.Close() is called
// in the production code.
clientPair := newChannelPair(t)
defer clientPair.closeConns()
// runnerPair simulates the gateway opening a channel to the backend runner.
// runnerPair.client == runnerChannel in handleChannel (gateway's client-side view).
runnerPair := newChannelPair(t)
defer runnerPair.closeConns()
clientChannel := clientPair.server // gateway accepted this from the external client
runnerChannel := runnerPair.client // gateway opened this to the runner
// done is closed when the main blocking io.Copy (runner→client) returns,
// proving that the keepalive context would be cancelled via defer cancel().
done := make(chan struct{})
// Mirror the goroutines from apps/ssh-gateway/main.go rather than calling handleChannel
// directly: handleChannel requires a live SSH server listener and API client, making it
// unsuitable for a unit test. The mechanism being tested (a dead client connection
// hard-closes the runner channel, unblocking the reverse io.Copy) is fully captured
// here without those dependencies.
go func() {
_, _ = io.Copy(runnerChannel, clientChannel)
runnerChannel.CloseWrite() //nolint:errcheck
}()
sessionDone := make(chan struct{})
defer close(sessionDone)
go func() {
select {
case <-clientPair.connClosed:
runnerChannel.Close() //nolint:errcheck
case <-sessionDone:
}
}()
// Main blocking copy: runner→client.
// This is the call that previously blocked forever after a client SIGKILL.
go func() {
_, _ = io.Copy(clientChannel, runnerChannel)
close(done)
}()
// Simulate abrupt SSH client disconnect (kill -9 / VS Code tab closed / network drop)
// by closing the underlying TCP connection. This fires connClosed, which triggers
// runnerChannel.Close().
clientPair.closeClient()
select {
case <-done:
// Both copies returned. In the real handleChannel, defer cancel() would now fire,
// stopping the keepalive ticker. lastActivityAt goes stale → auto-stop triggers.
case <-time.After(2 * time.Second):
t.Fatal("reverse channel copy (runner→client) did not unblock after client disconnect; " +
"the keepalive goroutine would run indefinitely, preventing auto-stop")
}
}
// TestRunnerChannelClosedAfterClientDisconnect verifies that the runner-side channel
// receives a close signal after the external client disconnects. This ensures orphaned
// sandbox shells (VS Code remote server, JetBrains Gateway, plain bash) are cleaned up.
func TestRunnerChannelClosedAfterClientDisconnect(t *testing.T) {
t.Parallel()
clientPair := newChannelPair(t)
defer clientPair.closeConns()
runnerPair := newChannelPair(t)
defer runnerPair.closeConns()
clientChannel := clientPair.server
runnerChannel := runnerPair.client
go func() {
_, _ = io.Copy(runnerChannel, clientChannel)
runnerChannel.CloseWrite() //nolint:errcheck
}()
sessionDone := make(chan struct{})
defer close(sessionDone)
go func() {
select {
case <-clientPair.connClosed:
runnerChannel.Close() //nolint:errcheck
case <-sessionDone:
}
}()
go func() {
_, _ = io.Copy(clientChannel, runnerChannel)
}()
// Simulate abrupt SSH client disconnect (SIGKILL / network drop).
clientPair.closeClient()
// The runner-side server channel (runnerPair.server) should receive EOF/close,
// which propagates to the runner's handleChannel → sandboxChannel.Close().
readErr := make(chan error, 1)
go func() {
buf := make([]byte, 1)
_, err := runnerPair.server.Read(buf)
readErr <- err
}()
select {
case err := <-readErr:
if err == nil {
t.Fatal("expected EOF/close on runner-side channel after client disconnect, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("runner-side channel was not closed after client disconnect; " +
"orphaned sandbox processes would not receive a hangup signal")
}
}
// TestStdinEOFPreservesHalfClose verifies that a clean stdin EOF from the client
// (e.g. `cat file | ssh host cmd`) only half-closes the runner channel: the remote
// command can still send output and exit-status afterwards. A hard Close() here would
// truncate them — the regression flagged in the PR review of the keepalive fix.
func TestStdinEOFPreservesHalfClose(t *testing.T) {
t.Parallel()
clientPair := newChannelPair(t)
defer clientPair.closeConns()
runnerPair := newChannelPair(t)
defer runnerPair.closeConns()
clientChannel := clientPair.server
runnerChannel := runnerPair.client
// Mirror the production goroutines from handleChannel.
go func() {
_, _ = io.Copy(runnerChannel, clientChannel)
runnerChannel.CloseWrite() //nolint:errcheck
}()
sessionDone := make(chan struct{})
defer close(sessionDone)
go func() {
select {
case <-clientPair.connClosed:
runnerChannel.Close() //nolint:errcheck
case <-sessionDone:
}
}()
go func() {
_, _ = io.Copy(clientChannel, runnerChannel)
}()
// Clean stdin EOF: the client half-closes its channel while the SSH
// connection stays alive.
if err := clientPair.client.CloseWrite(); err != nil {
t.Fatalf("client CloseWrite: %v", err)
}
// The runner side must observe EOF on its read...
eofErr := make(chan error, 1)
go func() {
_, err := runnerPair.server.Read(make([]byte, 1))
eofErr <- err
}()
select {
case err := <-eofErr:
if err != io.EOF {
t.Fatalf("expected io.EOF on runner side after stdin EOF, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("runner side did not observe stdin EOF")
}
// ...and must still be able to send output, which the client receives.
// With a hard Close() this write fails because the channel is torn down.
want := "late output"
if _, err := runnerPair.server.Write([]byte(want)); err != nil {
t.Fatalf("runner write after stdin EOF failed (half-close broken): %v", err)
}
got := make([]byte, len(want))
readDone := make(chan error, 1)
go func() {
_, err := io.ReadFull(clientPair.client, got)
readDone <- err
}()
select {
case err := <-readDone:
if err != nil {
t.Fatalf("client read after stdin EOF failed: %v", err)
}
if string(got) != want {
t.Fatalf("client got %q, want %q", got, want)
}
case <-time.After(2 * time.Second):
t.Fatal("client did not receive output sent after stdin EOF; half-close broken")
}
}