| 1 | package remote |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "net" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestClientUsesResolvedPerHopCredentials(t *testing.T) { |
| 12 | targetAuth := AuthOptions{Password: func() (string, error) { return "target", nil }} |
| 13 | hopAuth := AuthOptions{Password: func() (string, error) { return "jump", nil }} |
| 14 | c, err := New(Options{ |
| 15 | Host: ResolvedHost{HostName: "target", Port: 22, User: "target-user", ProxyJump: []string{"bastion"}}, |
| 16 | Auth: targetAuth, |
| 17 | JumpHosts: []JumpHostOptions{{Host: ResolvedHost{HostName: "10.0.0.8", Port: 2202, User: "jump-user"}, Auth: hopAuth}}, |
| 18 | }) |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | hop, auth, err := c.resolveHop("bastion") |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | if hop.Addr() != "10.0.0.8:2202" || hop.User != "jump-user" { |
| 27 | t.Fatalf("resolved hop = %+v", hop) |
| 28 | } |
| 29 | if auth.Password == nil { |
| 30 | t.Fatal("configured jump password was dropped") |
| 31 | } |
| 32 | jumpSecret, err := auth.Password() |
| 33 | if err != nil || jumpSecret != "jump" { |
| 34 | t.Fatalf("jump password = %q, %v", jumpSecret, err) |
| 35 | } |
| 36 | targetSecret, _ := targetAuth.Password() |
| 37 | if jumpSecret == targetSecret { |
| 38 | t.Fatal("jump auth reused the target credential") |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func TestClientKeepsAliasCredentialsDistinctForSharedEndpoint(t *testing.T) { |
| 43 | password := func(value string) func() (string, error) { |
| 44 | return func() (string, error) { return value, nil } |
| 45 | } |
| 46 | endpoint := ResolvedHost{HostName: "10.0.0.8", Port: 22, User: "jump-user"} |
| 47 | c, err := New(Options{ |
| 48 | Host: ResolvedHost{HostName: "target", Port: 22, User: "target-user", ProxyJump: []string{"primary", "backup"}}, |
| 49 | JumpHosts: []JumpHostOptions{ |
| 50 | {Host: endpoint, Auth: AuthOptions{Password: password("primary-secret")}}, |
| 51 | {Host: endpoint, Auth: AuthOptions{Password: password("backup-secret")}}, |
| 52 | }, |
| 53 | }) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | _, primary, err := c.resolveHop("primary") |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | _, backup, err := c.resolveHop("backup") |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | primarySecret, _ := primary.Password() |
| 66 | backupSecret, _ := backup.Password() |
| 67 | if primarySecret != "primary-secret" || backupSecret != "backup-secret" { |
| 68 | t.Fatalf("shared endpoint credentials collided: primary=%q backup=%q", primarySecret, backupSecret) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | type noDeadlineConn struct { |
| 73 | net.Conn |
| 74 | deadlineSet chan struct{} |
| 75 | } |
| 76 | |
| 77 | func (c noDeadlineConn) SetDeadline(time.Time) error { |
| 78 | select { |
| 79 | case c.deadlineSet <- struct{}{}: |
| 80 | default: |
| 81 | } |
| 82 | return errors.New("unsupported") |
| 83 | } |
| 84 | func (noDeadlineConn) RemoteAddr() net.Addr { |
| 85 | return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 22} |
| 86 | } |
| 87 | func (noDeadlineConn) SetReadDeadline(time.Time) error { return errors.New("unsupported") } |
| 88 | func (noDeadlineConn) SetWriteDeadline(time.Time) error { return errors.New("unsupported") } |
| 89 | |
| 90 | func TestSSHHandshakeHonorsContextWhenDeadlinesUnsupported(t *testing.T) { |
| 91 | client, server := net.Pipe() |
| 92 | defer client.Close() |
| 93 | defer server.Close() |
| 94 | ctx, cancel := context.WithCancel(context.Background()) |
| 95 | defer cancel() |
| 96 | done := make(chan error, 1) |
| 97 | deadlineSet := make(chan struct{}, 1) |
| 98 | go func() { |
| 99 | _, err := newSSHClient(ctx, noDeadlineConn{Conn: client, deadlineSet: deadlineSet}, |
| 100 | ResolvedHost{HostName: "target", Port: 22, User: "u", IdentityFileNone: true}, |
| 101 | &AuthOptions{DisableAgent: true}, &HostKeyPolicy{}, time.Second) |
| 102 | done <- err |
| 103 | }() |
| 104 | // SetDeadline runs after the cancellation watcher is installed and directly |
| 105 | // before the SSH handshake. Wait for that event instead of asserting that a |
| 106 | // loaded Windows runner schedules a 40 ms timer within a 500 ms wall clock. |
| 107 | select { |
| 108 | case <-deadlineSet: |
| 109 | case err := <-done: |
| 110 | t.Fatalf("handshake returned before installing its cancellation watcher: %v", err) |
| 111 | case <-time.After(5 * time.Second): |
| 112 | t.Fatal("handshake did not install its cancellation watcher") |
| 113 | } |
| 114 | cancel() |
| 115 | select { |
| 116 | case err := <-done: |
| 117 | if err == nil { |
| 118 | t.Fatal("banner-less handshake unexpectedly succeeded") |
| 119 | } |
| 120 | case <-time.After(5 * time.Second): |
| 121 | t.Fatal("handshake outlived its context on a ProxyJump-style connection") |
| 122 | } |
| 123 | } |
| 124 |