返回 DeepSeek-Reasonix
client_test.go
根目录 / internal / remote / client_test.go
1 package remote
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/remote/sshtest"
14 )
15
16 // managedOnlyPolicy points the host-key policy at an isolated managed file and
17 // no system files, with an accept-all prompt, so tests never touch ~/.ssh.
18 func managedOnlyPolicy(t *testing.T, accept bool) *HostKeyPolicy {
19 t.Helper()
20 return &HostKeyPolicy{
21 SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
22 ManagedPath: filepath.Join(t.TempDir(), "known_hosts"),
23 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
24 return accept, nil
25 },
26 }
27 }
28
29 func newTestClient(t *testing.T, srv *sshtest.Server, opts Options) *Client {
30 t.Helper()
31 host, err := ResolveHost(nil, "test@"+srv.Addr, nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 opts.Host = host
36 if opts.HostKeys == nil {
37 opts.HostKeys = managedOnlyPolicy(t, true)
38 }
39 c, err := New(opts)
40 if err != nil {
41 t.Fatal(err)
42 }
43 return c
44 }
45
46 func TestClientConnectPasswordAuth(t *testing.T) {
47 srv := sshtest.Start(t, sshtest.Options{Password: "hunter2"})
48 c := newTestClient(t, srv, Options{
49 Auth: AuthOptions{
50 DisableAgent: true,
51 Password: func() (string, error) { return "hunter2", nil },
52 },
53 })
54 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
55 defer cancel()
56 if err := c.Start(ctx); err != nil {
57 t.Fatalf("Start: %v", err)
58 }
59 defer c.Close()
60 if c.Status().Status != StatusConnected {
61 t.Fatalf("status = %v, want connected", c.Status().Status)
62 }
63 res, err := c.Exec(ctx, "echo hello")
64 if err != nil {
65 t.Fatalf("Exec: %v", err)
66 }
67 if strings.TrimSpace(string(res.Stdout)) != "echo hello" {
68 t.Fatalf("exec stdout = %q", res.Stdout)
69 }
70 }
71
72 func TestClientConnectPublicKeyAuth(t *testing.T) {
73 pemBytes, pub, err := sshtest.GenerateKeyPEM()
74 if err != nil {
75 t.Fatal(err)
76 }
77 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
78 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
79 if err := writeFile0600(keyPath, pemBytes); err != nil {
80 t.Fatal(err)
81 }
82 c := newTestClient(t, srv, Options{})
83 c.opts.Host.IdentityFile = keyPath
84 c.opts.Auth.DisableAgent = true
85
86 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
87 defer cancel()
88 if err := c.Start(ctx); err != nil {
89 t.Fatalf("Start: %v", err)
90 }
91 defer c.Close()
92 if c.Status().Status != StatusConnected {
93 t.Fatalf("status = %v", c.Status().Status)
94 }
95 }
96
97 func TestIdentityFileNoneSuppressesDefaultKeys(t *testing.T) {
98 pemBytes, pub, err := sshtest.GenerateKeyPEM()
99 if err != nil {
100 t.Fatal(err)
101 }
102 home := t.TempDir()
103 t.Setenv("HOME", home)
104 t.Setenv("USERPROFILE", home)
105 sshDir := filepath.Join(home, ".ssh")
106 if err := os.MkdirAll(sshDir, 0o700); err != nil {
107 t.Fatal(err)
108 }
109 if err := writeFile0600(filepath.Join(sshDir, "id_ed25519"), pemBytes); err != nil {
110 t.Fatal(err)
111 }
112 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
113 c := newTestClient(t, srv, Options{Auth: AuthOptions{DisableAgent: true}})
114 c.opts.Host.IdentityFileNone = true
115 c.opts.Host.IdentitiesOnly = true
116
117 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
118 defer cancel()
119 if err := c.Start(ctx); err == nil {
120 defer c.Close()
121 t.Fatal("IdentityFile none unexpectedly offered a default private key")
122 }
123 }
124
125 func TestClientTriesMultipleIdentityFilesInOrder(t *testing.T) {
126 wrongPEM, _, err := sshtest.GenerateKeyPEM()
127 if err != nil {
128 t.Fatal(err)
129 }
130 correctPEM, correctPublic, err := sshtest.GenerateKeyPEM()
131 if err != nil {
132 t.Fatal(err)
133 }
134 // The server accepts the second configured identity, not the first.
135 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: correctPublic})
136 dir := t.TempDir()
137 wrongPath := filepath.Join(dir, "id_wrong")
138 correctPath := filepath.Join(dir, "id_correct")
139 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
140 t.Fatal(err)
141 }
142 if err := writeFile0600(correctPath, correctPEM); err != nil {
143 t.Fatal(err)
144 }
145 c := newTestClient(t, srv, Options{})
146 c.opts.Host.IdentityFile = wrongPath
147 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
148 c.opts.Auth.DisableAgent = true
149
150 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
151 defer cancel()
152 if err := c.Start(ctx); err != nil {
153 t.Fatalf("Start with second valid identity: %v", err)
154 }
155 defer c.Close()
156 if c.Status().Status != StatusConnected {
157 t.Fatalf("status = %v, want connected", c.Status().Status)
158 }
159 }
160
161 func TestClientFallsBackFromUnavailableAgentToIdentityFile(t *testing.T) {
162 pemBytes, authorized, err := sshtest.GenerateKeyPEM()
163 if err != nil {
164 t.Fatal(err)
165 }
166 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
167 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
168 if err := writeFile0600(keyPath, pemBytes); err != nil {
169 t.Fatal(err)
170 }
171 t.Setenv("SSH_AUTH_SOCK", filepath.Join(t.TempDir(), "missing-agent.sock"))
172
173 c := newTestClient(t, srv, Options{})
174 c.opts.Host.IdentityFile = keyPath
175
176 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
177 defer cancel()
178 if err := c.Start(ctx); err != nil {
179 t.Fatalf("Start with unavailable agent and explicit identity: %v", err)
180 }
181 defer c.Close()
182 if c.Status().Status != StatusConnected {
183 t.Fatalf("status = %v, want connected", c.Status().Status)
184 }
185 }
186
187 func TestClientConnectEncryptedPublicKeyAuth(t *testing.T) {
188 pemBytes, pub, err := sshtest.GenerateEncryptedKeyPEM("correct horse battery staple")
189 if err != nil {
190 t.Fatal(err)
191 }
192 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: pub})
193 keyPath := filepath.Join(t.TempDir(), "id_ed25519")
194 if err := writeFile0600(keyPath, pemBytes); err != nil {
195 t.Fatal(err)
196 }
197 c := newTestClient(t, srv, Options{})
198 c.opts.Host.IdentityFile = keyPath
199 c.opts.Auth = AuthOptions{
200 DisableAgent: true,
201 Passphrase: func() (string, error) { return "correct horse battery staple", nil },
202 }
203
204 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
205 defer cancel()
206 if err := c.Start(ctx); err != nil {
207 t.Fatalf("Start: %v", err)
208 }
209 defer c.Close()
210 if c.Status().Status != StatusConnected {
211 t.Fatalf("status = %v, want connected", c.Status().Status)
212 }
213 }
214
215 func TestClientPromptsPerEncryptedIdentity(t *testing.T) {
216 wrongPEM, _, err := sshtest.GenerateEncryptedKeyPEM("first-key-passphrase")
217 if err != nil {
218 t.Fatal(err)
219 }
220 correctPEM, authorized, err := sshtest.GenerateEncryptedKeyPEM("second-key-passphrase")
221 if err != nil {
222 t.Fatal(err)
223 }
224 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
225 dir := t.TempDir()
226 wrongPath := filepath.Join(dir, "id_wrong_encrypted")
227 correctPath := filepath.Join(dir, "id_correct_encrypted")
228 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
229 t.Fatal(err)
230 }
231 if err := writeFile0600(correctPath, correctPEM); err != nil {
232 t.Fatal(err)
233 }
234 prompts := map[string]int{}
235 c := newTestClient(t, srv, Options{})
236 c.opts.Host.IdentityFile = wrongPath
237 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
238 c.opts.Auth = AuthOptions{
239 DisableAgent: true,
240 SecretPrompt: func(_ context.Context, kind SecretKind, _ string, identityFile string) (string, error) {
241 if kind != SecretPassphrase {
242 t.Fatalf("prompt kind = %v, want passphrase", kind)
243 }
244 prompts[identityFile]++
245 switch identityFile {
246 case wrongPath:
247 return "first-key-passphrase", nil
248 case correctPath:
249 return "second-key-passphrase", nil
250 default:
251 return "", fmt.Errorf("unexpected identity %q", identityFile)
252 }
253 },
254 }
255
256 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
257 defer cancel()
258 if err := c.Start(ctx); err != nil {
259 t.Fatalf("Start with separately encrypted identities: %v", err)
260 }
261 defer c.Close()
262 if prompts[wrongPath] != 1 || prompts[correctPath] != 1 {
263 t.Fatalf("passphrase prompts = %v, want one per identity", prompts)
264 }
265 }
266
267 func TestClientFallsBackFromStoredPassphraseToPerIdentityPrompt(t *testing.T) {
268 wrongPEM, _, err := sshtest.GenerateEncryptedKeyPEM("first-key-passphrase")
269 if err != nil {
270 t.Fatal(err)
271 }
272 correctPEM, authorized, err := sshtest.GenerateEncryptedKeyPEM("second-key-passphrase")
273 if err != nil {
274 t.Fatal(err)
275 }
276 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
277 dir := t.TempDir()
278 wrongPath := filepath.Join(dir, "id_wrong_encrypted")
279 correctPath := filepath.Join(dir, "id_correct_encrypted")
280 if err := writeFile0600(wrongPath, wrongPEM); err != nil {
281 t.Fatal(err)
282 }
283 if err := writeFile0600(correctPath, correctPEM); err != nil {
284 t.Fatal(err)
285 }
286 var prompted []string
287 c := newTestClient(t, srv, Options{})
288 c.opts.Host.IdentityFile = wrongPath
289 c.opts.Host.IdentityFiles = []string{wrongPath, correctPath}
290 c.opts.Auth = AuthOptions{
291 DisableAgent: true,
292 // The saved host-level value unlocks the second key only.
293 Passphrase: func() (string, error) { return "second-key-passphrase", nil },
294 SecretPrompt: func(_ context.Context, kind SecretKind, _ string, identityFile string) (string, error) {
295 if kind != SecretPassphrase || identityFile != wrongPath {
296 return "", fmt.Errorf("unexpected prompt kind=%v identity=%q", kind, identityFile)
297 }
298 prompted = append(prompted, identityFile)
299 return "first-key-passphrase", nil
300 },
301 }
302
303 // This handshake performs three passphrase KDFs (stored + prompted for the
304 // first identity, then stored for the second). Under full -race package
305 // parallelism on a constrained CI runner, ten seconds is too close to the CPU
306 // bound work even though the in-process SSH server remains responsive.
307 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
308 defer cancel()
309 if err := c.Start(ctx); err != nil {
310 t.Fatalf("Start with stored and per-identity passphrases: %v", err)
311 }
312 defer c.Close()
313 if len(prompted) != 1 || prompted[0] != wrongPath {
314 t.Fatalf("identity prompts = %v, want only %q", prompted, wrongPath)
315 }
316 }
317
318 func TestRejectedPublicKeyDoesNotReportMissingPasswordPrompt(t *testing.T) {
319 _, authorized, err := sshtest.GenerateKeyPEM()
320 if err != nil {
321 t.Fatal(err)
322 }
323 wrongPEM, _, err := sshtest.GenerateKeyPEM()
324 if err != nil {
325 t.Fatal(err)
326 }
327 srv := sshtest.Start(t, sshtest.Options{AuthorizedKey: authorized})
328 keyPath := filepath.Join(t.TempDir(), "wrong_id_ed25519")
329 if err := writeFile0600(keyPath, wrongPEM); err != nil {
330 t.Fatal(err)
331 }
332 c := newTestClient(t, srv, Options{})
333 c.opts.Host.IdentityFile = keyPath
334 c.opts.Auth.DisableAgent = true
335
336 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
337 defer cancel()
338 err = c.Start(ctx)
339 if err == nil {
340 t.Fatal("expected authentication failure")
341 }
342 if !errors.Is(err, ErrAuthFailed) {
343 t.Fatalf("error = %v, want ErrAuthFailed", err)
344 }
345 if strings.Contains(err.Error(), "password required") || strings.Contains(err.Error(), "no prompt available") {
346 t.Fatalf("public-key rejection was masked by a password-prompt error: %v", err)
347 }
348 }
349
350 func TestClientAuthFailureStops(t *testing.T) {
351 srv := sshtest.Start(t, sshtest.Options{Password: "correct"})
352 c := newTestClient(t, srv, Options{
353 Auth: AuthOptions{
354 DisableAgent: true,
355 Password: func() (string, error) { return "wrong", nil },
356 },
357 })
358 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
359 defer cancel()
360 err := c.Start(ctx)
361 if err == nil {
362 t.Fatal("expected auth failure")
363 }
364 if c.Status().Status != StatusStopped {
365 t.Fatalf("status = %v, want stopped", c.Status().Status)
366 }
367 }
368
369 func TestClientHostKeyRejectedStops(t *testing.T) {
370 srv := sshtest.Start(t, sshtest.Options{Password: "x"})
371 c := newTestClient(t, srv, Options{
372 HostKeys: managedOnlyPolicy(t, false), // reject TOFU
373 Auth: AuthOptions{
374 DisableAgent: true,
375 Password: func() (string, error) { return "x", nil },
376 },
377 })
378 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
379 defer cancel()
380 err := c.Start(ctx)
381 if err == nil {
382 t.Fatal("expected host key rejection")
383 }
384 }
385
386 func TestClientHostKeyTOFUPersistsAndReconnectsSilently(t *testing.T) {
387 srv := sshtest.Start(t, sshtest.Options{Password: "x"})
388 managed := filepath.Join(t.TempDir(), "known_hosts")
389 prompted := 0
390 policy := &HostKeyPolicy{
391 SystemKnownHosts: []string{filepath.Join(t.TempDir(), "none")},
392 ManagedPath: managed,
393 Prompt: func(context.Context, HostKeyQuestion) (bool, error) {
394 prompted++
395 return true, nil
396 },
397 }
398 host, _ := ResolveHost(nil, "test@"+srv.Addr, nil)
399 mkClient := func() *Client {
400 c, err := New(Options{
401 Host: host,
402 HostKeys: policy,
403 Auth: AuthOptions{DisableAgent: true, Password: func() (string, error) { return "x", nil }},
404 })
405 if err != nil {
406 t.Fatal(err)
407 }
408 return c
409 }
410
411 ctx := context.Background()
412 c1 := mkClient()
413 if err := c1.Start(ctx); err != nil {
414 t.Fatalf("first connect: %v", err)
415 }
416 c1.Close()
417 if prompted != 1 {
418 t.Fatalf("expected exactly 1 prompt on first connect, got %d", prompted)
419 }
420
421 // Second connect should find the key in the managed file: no prompt.
422 c2 := mkClient()
423 if err := c2.Start(ctx); err != nil {
424 t.Fatalf("second connect: %v", err)
425 }
426 c2.Close()
427 if prompted != 1 {
428 t.Fatalf("second connect re-prompted (count=%d); TOFU key was not persisted", prompted)
429 }
430 }
431
432 func writeFile0600(path string, data []byte) error {
433 return os.WriteFile(path, data, 0o600)
434 }
435
435 lines GO