1
1
package cli_test
2
2
3
3
import (
4
+ "context"
5
+ "crypto/ecdsa"
6
+ "crypto/elliptic"
7
+ "crypto/rand"
8
+ "errors"
4
9
"io"
5
10
"net"
11
+ "path/filepath"
6
12
"runtime"
7
13
"testing"
8
14
"time"
@@ -11,9 +17,11 @@ import (
11
17
"github.com/stretchr/testify/assert"
12
18
"github.com/stretchr/testify/require"
13
19
"golang.org/x/crypto/ssh"
20
+ gosshagent "golang.org/x/crypto/ssh/agent"
14
21
15
22
"cdr.dev/slog"
16
23
"cdr.dev/slog/sloggers/slogtest"
24
+
17
25
"github.com/coder/coder/agent"
18
26
"github.com/coder/coder/cli/clitest"
19
27
"github.com/coder/coder/coderd/coderdtest"
@@ -23,49 +31,53 @@ import (
23
31
"github.com/coder/coder/pty/ptytest"
24
32
)
25
33
34
+ func setupWorkspaceForSSH (t * testing.T ) (* codersdk.Client , codersdk.Workspace , string ) {
35
+ t .Helper ()
36
+ client := coderdtest .New (t , & coderdtest.Options {IncludeProvisionerD : true })
37
+ user := coderdtest .CreateFirstUser (t , client )
38
+ agentToken := uuid .NewString ()
39
+ version := coderdtest .CreateTemplateVersion (t , client , user .OrganizationID , & echo.Responses {
40
+ Parse : echo .ParseComplete ,
41
+ ProvisionDryRun : echo .ProvisionComplete ,
42
+ Provision : []* proto.Provision_Response {{
43
+ Type : & proto.Provision_Response_Complete {
44
+ Complete : & proto.Provision_Complete {
45
+ Resources : []* proto.Resource {{
46
+ Name : "dev" ,
47
+ Type : "google_compute_instance" ,
48
+ Agents : []* proto.Agent {{
49
+ Id : uuid .NewString (),
50
+ Auth : & proto.Agent_Token {
51
+ Token : agentToken ,
52
+ },
53
+ }},
54
+ }},
55
+ },
56
+ },
57
+ }},
58
+ })
59
+ coderdtest .AwaitTemplateVersionJob (t , client , version .ID )
60
+ template := coderdtest .CreateTemplate (t , client , user .OrganizationID , version .ID )
61
+ workspace := coderdtest .CreateWorkspace (t , client , user .OrganizationID , template .ID )
62
+
63
+ return client , workspace , agentToken
64
+ }
65
+
26
66
func TestSSH (t * testing.T ) {
27
- t .Skip ("This is causing test flakes. TODO @cian fix this" )
28
67
t .Parallel ()
29
68
t .Run ("ImmediateExit" , func (t * testing.T ) {
30
69
t .Parallel ()
31
- client := coderdtest .New (t , & coderdtest.Options {IncludeProvisionerD : true })
32
- user := coderdtest .CreateFirstUser (t , client )
33
- agentToken := uuid .NewString ()
34
- version := coderdtest .CreateTemplateVersion (t , client , user .OrganizationID , & echo.Responses {
35
- Parse : echo .ParseComplete ,
36
- ProvisionDryRun : echo .ProvisionComplete ,
37
- Provision : []* proto.Provision_Response {{
38
- Type : & proto.Provision_Response_Complete {
39
- Complete : & proto.Provision_Complete {
40
- Resources : []* proto.Resource {{
41
- Name : "dev" ,
42
- Type : "google_compute_instance" ,
43
- Agents : []* proto.Agent {{
44
- Id : uuid .NewString (),
45
- Auth : & proto.Agent_Token {
46
- Token : agentToken ,
47
- },
48
- }},
49
- }},
50
- },
51
- },
52
- }},
53
- })
54
- coderdtest .AwaitTemplateVersionJob (t , client , version .ID )
55
- template := coderdtest .CreateTemplate (t , client , user .OrganizationID , version .ID )
56
- workspace := coderdtest .CreateWorkspace (t , client , user .OrganizationID , template .ID )
70
+ client , workspace , agentToken := setupWorkspaceForSSH (t )
57
71
cmd , root := clitest .New (t , "ssh" , workspace .Name )
58
72
clitest .SetupConfig (t , client , root )
59
- doneChan := make (chan struct {})
60
73
pty := ptytest .New (t )
61
74
cmd .SetIn (pty .Input ())
62
75
cmd .SetErr (pty .Output ())
63
76
cmd .SetOut (pty .Output ())
64
- go func () {
65
- defer close (doneChan )
77
+ cmdDone := tGo (t , func () {
66
78
err := cmd .Execute ()
67
79
assert .NoError (t , err )
68
- }( )
80
+ })
69
81
pty .ExpectMatch ("Waiting" )
70
82
coderdtest .AwaitWorkspaceBuildJob (t , client , workspace .LatestBuild .ID )
71
83
agentClient := codersdk .New (client .URL )
@@ -76,39 +88,16 @@ func TestSSH(t *testing.T) {
76
88
t .Cleanup (func () {
77
89
_ = agentCloser .Close ()
78
90
})
91
+
79
92
// Shells on Mac, Windows, and Linux all exit shells with the "exit" command.
80
93
pty .WriteLine ("exit" )
81
- <- doneChan
94
+ <- cmdDone
82
95
})
83
96
t .Run ("Stdio" , func (t * testing.T ) {
84
97
t .Parallel ()
85
- client := coderdtest .New (t , & coderdtest.Options {IncludeProvisionerD : true })
86
- user := coderdtest .CreateFirstUser (t , client )
87
- agentToken := uuid .NewString ()
88
- version := coderdtest .CreateTemplateVersion (t , client , user .OrganizationID , & echo.Responses {
89
- Parse : echo .ParseComplete ,
90
- ProvisionDryRun : echo .ProvisionComplete ,
91
- Provision : []* proto.Provision_Response {{
92
- Type : & proto.Provision_Response_Complete {
93
- Complete : & proto.Provision_Complete {
94
- Resources : []* proto.Resource {{
95
- Name : "dev" ,
96
- Type : "google_compute_instance" ,
97
- Agents : []* proto.Agent {{
98
- Id : uuid .NewString (),
99
- Auth : & proto.Agent_Token {
100
- Token : agentToken ,
101
- },
102
- }},
103
- }},
104
- },
105
- },
106
- }},
107
- })
108
- coderdtest .AwaitTemplateVersionJob (t , client , version .ID )
109
- template := coderdtest .CreateTemplate (t , client , user .OrganizationID , version .ID )
110
- workspace := coderdtest .CreateWorkspace (t , client , user .OrganizationID , template .ID )
111
- go func () {
98
+ client , workspace , agentToken := setupWorkspaceForSSH (t )
99
+
100
+ _ , _ = tGoContext (t , func (ctx context.Context ) {
112
101
// Run this async so the SSH command has to wait for
113
102
// the build and agent to connect!
114
103
coderdtest .AwaitWorkspaceBuildJob (t , client , workspace .LatestBuild .ID )
@@ -117,25 +106,22 @@ func TestSSH(t *testing.T) {
117
106
agentCloser := agent .New (agentClient .ListenWorkspaceAgent , & agent.Options {
118
107
Logger : slogtest .Make (t , nil ).Leveled (slog .LevelDebug ),
119
108
})
120
- t .Cleanup (func () {
121
- _ = agentCloser .Close ()
122
- })
123
- }()
109
+ <- ctx .Done ()
110
+ _ = agentCloser .Close ()
111
+ })
124
112
125
113
clientOutput , clientInput := io .Pipe ()
126
114
serverOutput , serverInput := io .Pipe ()
127
115
128
116
cmd , root := clitest .New (t , "ssh" , "--stdio" , workspace .Name )
129
117
clitest .SetupConfig (t , client , root )
130
- doneChan := make (chan struct {})
131
118
cmd .SetIn (clientOutput )
132
119
cmd .SetOut (serverInput )
133
120
cmd .SetErr (io .Discard )
134
- go func () {
135
- defer close (doneChan )
121
+ cmdDone := tGo (t , func () {
136
122
err := cmd .Execute ()
137
123
assert .NoError (t , err )
138
- }( )
124
+ })
139
125
140
126
conn , channels , requests , err := ssh .NewClientConn (& stdioConn {
141
127
Reader : serverOutput ,
@@ -157,8 +143,135 @@ func TestSSH(t *testing.T) {
157
143
err = sshClient .Close ()
158
144
require .NoError (t , err )
159
145
_ = clientOutput .Close ()
160
- <- doneChan
146
+
147
+ <- cmdDone
148
+ })
149
+ //nolint:paralleltest // Disabled due to use of t.Setenv.
150
+ t .Run ("ForwardAgent" , func (t * testing.T ) {
151
+ if runtime .GOOS == "windows" {
152
+ t .Skip ("Test not supported on windows" )
153
+ }
154
+
155
+ client , workspace , agentToken := setupWorkspaceForSSH (t )
156
+
157
+ _ , _ = tGoContext (t , func (ctx context.Context ) {
158
+ // Run this async so the SSH command has to wait for
159
+ // the build and agent to connect!
160
+ coderdtest .AwaitWorkspaceBuildJob (t , client , workspace .LatestBuild .ID )
161
+ agentClient := codersdk .New (client .URL )
162
+ agentClient .SessionToken = agentToken
163
+ agentCloser := agent .New (agentClient .ListenWorkspaceAgent , & agent.Options {
164
+ Logger : slogtest .Make (t , nil ).Leveled (slog .LevelDebug ),
165
+ })
166
+ <- ctx .Done ()
167
+ _ = agentCloser .Close ()
168
+ })
169
+
170
+ // Generate private key.
171
+ privateKey , err := ecdsa .GenerateKey (elliptic .P256 (), rand .Reader )
172
+ require .NoError (t , err )
173
+ kr := gosshagent .NewKeyring ()
174
+ kr .Add (gosshagent.AddedKey {
175
+ PrivateKey : privateKey ,
176
+ })
177
+
178
+ // Start up ssh agent listening on unix socket.
179
+ tmpdir := t .TempDir ()
180
+ agentSock := filepath .Join (tmpdir , "agent.sock" )
181
+ l , err := net .Listen ("unix" , agentSock )
182
+ require .NoError (t , err )
183
+ defer l .Close ()
184
+ _ = tGo (t , func () {
185
+ for {
186
+ fd , err := l .Accept ()
187
+ if err != nil {
188
+ if ! errors .Is (err , net .ErrClosed ) {
189
+ t .Logf ("accept error: %v" , err )
190
+ }
191
+ return
192
+ }
193
+
194
+ err = gosshagent .ServeAgent (kr , fd )
195
+ if ! errors .Is (err , io .EOF ) {
196
+ assert .NoError (t , err )
197
+ }
198
+ }
199
+ })
200
+
201
+ t .Setenv ("SSH_AUTH_SOCK" , agentSock )
202
+ cmd , root := clitest .New (t ,
203
+ "ssh" ,
204
+ workspace .Name ,
205
+ "--forward-agent" ,
206
+ )
207
+ clitest .SetupConfig (t , client , root )
208
+ pty := ptytest .New (t )
209
+ cmd .SetIn (pty .Input ())
210
+ cmd .SetOut (pty .Output ())
211
+ cmd .SetErr (io .Discard )
212
+ cmdDone := tGo (t , func () {
213
+ err := cmd .Execute ()
214
+ assert .NoError (t , err )
215
+ })
216
+
217
+ // Ensure that SSH_AUTH_SOCK is set.
218
+ // Linux: /tmp/auth-agent3167016167/listener.sock
219
+ // macOS: /var/folders/ng/m1q0wft14hj0t3rtjxrdnzsr0000gn/T/auth-agent3245553419/listener.sock
220
+ pty .WriteLine ("env" )
221
+ pty .ExpectMatch ("SSH_AUTH_SOCK=" )
222
+ // Ensure that ssh-add lists our key.
223
+ pty .WriteLine ("ssh-add -L" )
224
+ keys , err := kr .List ()
225
+ require .NoError (t , err )
226
+ pty .ExpectMatch (keys [0 ].String ())
227
+
228
+ // And we're done.
229
+ pty .WriteLine ("exit" )
230
+ <- cmdDone
231
+ })
232
+ }
233
+
234
+ // tGoContext runs fn in a goroutine passing a context that will be
235
+ // canceled on test completion and wait until fn has finished executing.
236
+ // Done and cancel are returned for optionally waiting until completion
237
+ // or early cancellation.
238
+ //
239
+ // NOTE(mafredri): This could be moved to a helper library.
240
+ func tGoContext (t * testing.T , fn func (context.Context )) (done <- chan struct {}, cancel context.CancelFunc ) {
241
+ t .Helper ()
242
+
243
+ ctx , cancel := context .WithCancel (context .Background ())
244
+ doneC := make (chan struct {})
245
+ t .Cleanup (func () {
246
+ cancel ()
247
+ <- done
248
+ })
249
+ go func () {
250
+ fn (ctx )
251
+ close (doneC )
252
+ }()
253
+
254
+ return doneC , cancel
255
+ }
256
+
257
+ // tGo runs fn in a goroutine and waits until fn has completed before
258
+ // test completion. Done is returned for optionally waiting for fn to
259
+ // exit.
260
+ //
261
+ // NOTE(mafredri): This could be moved to a helper library.
262
+ func tGo (t * testing.T , fn func ()) (done <- chan struct {}) {
263
+ t .Helper ()
264
+
265
+ doneC := make (chan struct {})
266
+ t .Cleanup (func () {
267
+ <- doneC
161
268
})
269
+ go func () {
270
+ fn ()
271
+ close (doneC )
272
+ }()
273
+
274
+ return doneC
162
275
}
163
276
164
277
type stdioConn struct {
0 commit comments