Skip to content

Commit 39ce87a

Browse files
authored
fix: Ssh node with port (#403)
ssh node with port
1 parent f87411f commit 39ce87a

6 files changed

Lines changed: 100 additions & 71 deletions

File tree

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ module github.com/brevdev/brev-cli
33
go 1.25.0
44

55
require (
6-
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1
7-
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1
8-
connectrpc.com/connect v1.19.2
6+
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1
7+
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1
8+
connectrpc.com/connect v1.20.0
99
github.com/NVIDIA/go-nvml v0.13.0-1
1010
github.com/alessio/shellescape v1.4.1
1111
github.com/brevdev/parse v0.0.11

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1 h1:OtdZWOk/dypzAe4bylO+TFfcw9J3Ndyeh1yylWSNgRc=
2-
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1/go.mod h1:eaa0R5ozu4wxcy62DEtRxO6hahJ0WuFsMAG33Zj/lVQ=
3-
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1 h1:fDUuYv/K3h8IpEGf0uic/1/A1nBN+Vao4jzVWDRMLLc=
4-
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo=
1+
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1 h1:p2gDnCmIeMzMuRNP05Jh143Q8iiSq0/oXG8eckzCkSY=
2+
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1/go.mod h1:CwGL+2J9G36DvGlMYW/5f+LTnGAOGJPcAw3S/Zy7lbk=
3+
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1 h1:NyJ55L5BmM+AOC77hUrLysVvzU4m9YO+g93YwvZS3Y4=
4+
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo=
55
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo=
66
buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g=
77
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
@@ -41,8 +41,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
4141
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
4242
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
4343
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
44-
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
45-
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
44+
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
45+
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
4646
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
4747
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
4848
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=

pkg/cmd/refresh/refresh_test.go

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ func TestResolveNodeSSHEntry_HappyPath(t *testing.T) {
1414
node := &nodev1.ExternalNode{
1515
Name: "My GPU Box",
1616
SshAccess: []*nodev1.SSHAccess{
17-
{UserId: "user_1", LinuxUser: "ec2-user"},
17+
{UserId: "user_1", LinuxUser: "ec2-user", PortId: "port_1"},
1818
},
1919
Ports: []*nodev1.Port{
2020
{
21-
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
21+
PortId: "port_1",
22+
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP,
2223
PortNumber: 41920,
2324
ServerPort: 22,
2425
Hostname: strPtr("10.0.0.5"),
@@ -48,11 +49,12 @@ func TestResolveNodeSSHEntry_UsesServerPortNotPortNumber(t *testing.T) {
4849
node := &nodev1.ExternalNode{
4950
Name: "test-node",
5051
SshAccess: []*nodev1.SSHAccess{
51-
{UserId: "user_1", LinuxUser: "ubuntu"},
52+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"},
5253
},
5354
Ports: []*nodev1.Port{
5455
{
55-
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
56+
PortId: "port_1",
57+
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP,
5658
PortNumber: 51234, // netbird-assigned port — correct
5759
ServerPort: 22, // well-known port — NOT what we should connect to
5860
Hostname: strPtr("gateway.example.com"),
@@ -76,7 +78,7 @@ func TestResolveNodeSSHEntry_SkipsNoAccess(t *testing.T) {
7678
{UserId: "other_user", LinuxUser: "ubuntu"},
7779
},
7880
Ports: []*nodev1.Port{
79-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22, Hostname: strPtr("h")},
81+
{PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22, Hostname: strPtr("h")},
8082
},
8183
}
8284

@@ -86,31 +88,34 @@ func TestResolveNodeSSHEntry_SkipsNoAccess(t *testing.T) {
8688
}
8789
}
8890

89-
func TestResolveNodeSSHEntry_SkipsNoSSHPort(t *testing.T) {
91+
func TestResolveNodeSSHEntry_UsesAccessPortNotProtocol(t *testing.T) {
9092
node := &nodev1.ExternalNode{
9193
Name: "box",
9294
SshAccess: []*nodev1.SSHAccess{
93-
{UserId: "user_1", LinuxUser: "ubuntu"},
95+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_tcp"},
9496
},
9597
Ports: []*nodev1.Port{
96-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 8080, Hostname: strPtr("h")},
98+
{PortId: "port_tcp", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 51234, ServerPort: 22, Hostname: strPtr("h")},
9799
},
98100
}
99101

100102
entry := util.ResolveNodeSSHEntry("user_1", node)
101-
if entry != nil {
102-
t.Errorf("expected nil for no SSH port, got %+v", entry)
103+
if entry == nil {
104+
t.Fatal("expected entry for TCP port with access")
105+
}
106+
if entry.Port != 51234 {
107+
t.Errorf("expected port 51234, got %d", entry.Port)
103108
}
104109
}
105110

106111
func TestResolveNodeSSHEntry_SkipsEmptyHostname(t *testing.T) {
107112
node := &nodev1.ExternalNode{
108113
Name: "box",
109114
SshAccess: []*nodev1.SSHAccess{
110-
{UserId: "user_1", LinuxUser: "ubuntu"},
115+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"},
111116
},
112117
Ports: []*nodev1.Port{
113-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22},
118+
{PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22},
114119
},
115120
}
116121

@@ -120,33 +125,50 @@ func TestResolveNodeSSHEntry_SkipsEmptyHostname(t *testing.T) {
120125
}
121126
}
122127

128+
func TestResolveNodeSSHEntry_SkipsWhenPortIDMissing(t *testing.T) {
129+
node := &nodev1.ExternalNode{
130+
Name: "box",
131+
SshAccess: []*nodev1.SSHAccess{
132+
{UserId: "user_1", LinuxUser: "ubuntu"},
133+
},
134+
Ports: []*nodev1.Port{
135+
{PortId: "port_a", PortNumber: 41000, ServerPort: 22, Hostname: strPtr("10.0.0.1")},
136+
},
137+
}
138+
139+
entry := util.ResolveNodeSSHEntry("user_1", node)
140+
if entry != nil {
141+
t.Errorf("expected nil without PortId on access, got %+v", entry)
142+
}
143+
}
144+
123145
func TestResolveNodeSSHEntry_MultipleNodes(t *testing.T) {
124146
nodes := []*nodev1.ExternalNode{
125147
{
126148
Name: "Node A",
127149
SshAccess: []*nodev1.SSHAccess{
128-
{UserId: "user_1", LinuxUser: "ubuntu"},
150+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_a"},
129151
},
130152
Ports: []*nodev1.Port{
131-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 41000, Hostname: strPtr("10.0.0.1")},
153+
{PortId: "port_a", PortNumber: 41000, ServerPort: 22, Hostname: strPtr("10.0.0.1")},
132154
},
133155
},
134156
{
135157
Name: "Node B",
136158
SshAccess: []*nodev1.SSHAccess{
137-
{UserId: "other_user", LinuxUser: "root"},
159+
{UserId: "other_user", LinuxUser: "root", PortId: "port_b"},
138160
},
139161
Ports: []*nodev1.Port{
140-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 42000, Hostname: strPtr("10.0.0.2")},
162+
{PortId: "port_b", PortNumber: 42000, ServerPort: 22, Hostname: strPtr("10.0.0.2")},
141163
},
142164
},
143165
{
144166
Name: "Node C",
145167
SshAccess: []*nodev1.SSHAccess{
146-
{UserId: "user_1", LinuxUser: "admin"},
168+
{UserId: "user_1", LinuxUser: "admin", PortId: "port_c"},
147169
},
148170
Ports: []*nodev1.Port{
149-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 43000, Hostname: strPtr("10.0.0.3")},
171+
{PortId: "port_c", PortNumber: 43000, ServerPort: 22, Hostname: strPtr("10.0.0.3")},
150172
},
151173
},
152174
}

pkg/cmd/shell/shell_test.go

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ func TestResolveExternalNodeSSH_BuildsCorrectInfo(t *testing.T) {
1717
node := &nodev1.ExternalNode{
1818
Name: "My GPU Box",
1919
SshAccess: []*nodev1.SSHAccess{
20-
{UserId: "user_1", LinuxUser: "ec2-user"},
20+
{UserId: "user_1", LinuxUser: "ec2-user", PortId: "port_1"},
2121
},
2222
Ports: []*nodev1.Port{
2323
{
24-
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
24+
PortId: "port_1",
25+
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP,
2526
PortNumber: 41920,
2627
ServerPort: 22,
2728
Hostname: strPtr("10.0.0.5"),
@@ -82,21 +83,18 @@ func TestResolveExternalNodeSSH_NoAccess(t *testing.T) {
8283
}
8384
}
8485

85-
// TestResolveExternalNodeSSH_NoSSHPort verifies that a node with no SSH port
86-
// returns nil even when the user has access.
87-
func TestResolveExternalNodeSSH_NoSSHPort(t *testing.T) {
86+
// TestResolveExternalNodeSSH_NoPorts verifies nil when the user has access but no ports exist.
87+
func TestResolveExternalNodeSSH_NoPorts(t *testing.T) {
8888
node := &nodev1.ExternalNode{
89-
Name: "no-ssh",
89+
Name: "no-ports",
9090
SshAccess: []*nodev1.SSHAccess{
91-
{UserId: "user_1", LinuxUser: "ubuntu"},
92-
},
93-
Ports: []*nodev1.Port{
94-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 8080, Hostname: strPtr("10.0.0.1")},
91+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_missing"},
9592
},
93+
Ports: nil,
9694
}
9795

9896
entry := util.ResolveNodeSSHEntry("user_1", node)
9997
if entry != nil {
100-
t.Errorf("expected nil for node without SSH port, got %+v", entry)
98+
t.Errorf("expected nil for node without ports, got %+v", entry)
10199
}
102100
}

pkg/cmd/util/externalnode.go

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -72,38 +72,41 @@ func (info *ExternalNodeSSHInfo) HomePath() string {
7272
}
7373

7474
// ResolveNodeSSHEntry is a pure data function that extracts the SSH config entry
75-
// for a given user from a node. Returns nil if the user has no access or the node
76-
// has no SSH port. This is the single source of truth for node→SSHEntry conversion,
77-
// used by both ResolveExternalNodeSSH (for commands) and refresh (for SSH config generation).
75+
// for a given user from a node. Returns nil if the user has no SSH access or no
76+
// resolvable port with a hostname. Uses the port matching the user's access PortId.
7877
func ResolveNodeSSHEntry(userID string, node *nodev1.ExternalNode) *ssh.ExternalNodeSSHEntry {
79-
var linuxUser string
80-
for _, access := range node.GetSshAccess() {
81-
if access.GetUserId() == userID {
82-
linuxUser = access.GetLinuxUser()
78+
var access *nodev1.SSHAccess
79+
for _, a := range node.GetSshAccess() {
80+
if a.GetUserId() == userID {
81+
access = a
8382
break
8483
}
8584
}
86-
if linuxUser == "" {
85+
if access == nil || access.GetLinuxUser() == "" {
8786
return nil
8887
}
8988

90-
var sshPort *nodev1.Port
91-
for _, p := range node.GetPorts() {
92-
if p.GetProtocol() == nodev1.PortProtocol_PORT_PROTOCOL_SSH {
93-
sshPort = p
94-
break
95-
}
96-
}
97-
if sshPort == nil || sshPort.GetHostname() == "" {
89+
port := resolvePortForSSHAccess(node, access)
90+
if port == nil || port.GetHostname() == "" {
9891
return nil
9992
}
10093

10194
return &ssh.ExternalNodeSSHEntry{
10295
Alias: ssh.SanitizeNodeName(node.GetName()),
103-
Hostname: sshPort.GetHostname(),
104-
Port: sshPort.GetPortNumber(),
105-
User: linuxUser,
96+
Hostname: port.GetHostname(),
97+
Port: port.GetPortNumber(),
98+
User: access.GetLinuxUser(),
99+
}
100+
}
101+
102+
func resolvePortForSSHAccess(node *nodev1.ExternalNode, access *nodev1.SSHAccess) *nodev1.Port {
103+
portID := access.GetPortId()
104+
for _, p := range node.GetPorts() {
105+
if p.GetPortId() == portID {
106+
return p
107+
}
106108
}
109+
return nil
107110
}
108111

109112
// OpenPort calls the OpenPort RPC to open a port on an external node via netbird.
@@ -144,7 +147,7 @@ func FindExternalNode(store ExternalNodeStore, name string) (*nodev1.ExternalNod
144147
}
145148

146149
// ResolveExternalNodeSSH resolves the SSH connection details for an external node
147-
// by finding the current user's SSH access and the node's SSH port.
150+
// by finding the current user's SSH access and the allocated port for that access.
148151
func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode) (*ExternalNodeSSHInfo, error) {
149152
user, err := store.GetCurrentUser()
150153
if err != nil {
@@ -153,7 +156,7 @@ func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode)
153156

154157
entry := ResolveNodeSSHEntry(user.ID, node)
155158
if entry == nil {
156-
return nil, breverrors.New(fmt.Sprintf("cannot resolve SSH for node %q — no access, no SSH port, or no hostname", node.GetName()))
159+
return nil, breverrors.New(fmt.Sprintf("cannot resolve SSH for node %q — no access, no port, or no hostname", node.GetName()))
157160
}
158161

159162
return &ExternalNodeSSHInfo{

pkg/cmd/util/externalnode_test.go

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,17 @@ func (m *mockExternalNodeStore) GetCurrentUser() (*entity.User, error) {
3333
func strPtr(s string) *string { return &s }
3434

3535
func makeTestNode(name, userID, linuxUser, hostname string, portNumber int32) *nodev1.ExternalNode {
36+
portID := "port_test"
3637
return &nodev1.ExternalNode{
3738
ExternalNodeId: "unode_test",
3839
Name: name,
3940
SshAccess: []*nodev1.SSHAccess{
40-
{UserId: userID, LinuxUser: linuxUser},
41+
{UserId: userID, LinuxUser: linuxUser, PortId: portID},
4142
},
4243
Ports: []*nodev1.Port{
4344
{
44-
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
45+
PortId: portID,
46+
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP,
4547
PortNumber: portNumber,
4648
ServerPort: 22,
4749
Hostname: &hostname,
@@ -78,11 +80,12 @@ func TestResolveExternalNodeSSH_UsesServerPortNotPortNumber(t *testing.T) {
7880
node := &nodev1.ExternalNode{
7981
Name: "test-node",
8082
SshAccess: []*nodev1.SSHAccess{
81-
{UserId: "user_1", LinuxUser: "ubuntu"},
83+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"},
8284
},
8385
Ports: []*nodev1.Port{
8486
{
85-
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
87+
PortId: "port_1",
88+
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP,
8689
PortNumber: 41920, // netbird-assigned port — this is correct
8790
ServerPort: 22, // well-known port — NOT what we connect to
8891
Hostname: strPtr("gateway.example.com"),
@@ -111,23 +114,26 @@ func TestResolveExternalNodeSSH_NoAccess(t *testing.T) {
111114
}
112115
}
113116

114-
func TestResolveExternalNodeSSH_NoSSHPort(t *testing.T) {
117+
func TestResolveExternalNodeSSH_UsesAccessPortNotProtocol(t *testing.T) {
115118
store := &mockExternalNodeStore{
116119
user: &entity.User{ID: "user_1"},
117120
}
118121
node := &nodev1.ExternalNode{
119122
Name: "box",
120123
SshAccess: []*nodev1.SSHAccess{
121-
{UserId: "user_1", LinuxUser: "ubuntu"},
124+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_tcp"},
122125
},
123126
Ports: []*nodev1.Port{
124-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, Hostname: strPtr("h")},
127+
{PortId: "port_tcp", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 51234, ServerPort: 22, Hostname: strPtr("h")},
125128
},
126129
}
127130

128-
_, err := ResolveExternalNodeSSH(store, node)
129-
if err == nil {
130-
t.Fatal("expected error for no SSH port")
131+
info, err := ResolveExternalNodeSSH(store, node)
132+
if err != nil {
133+
t.Fatalf("unexpected error: %v", err)
134+
}
135+
if info.Port != 51234 {
136+
t.Errorf("expected port 51234, got %d", info.Port)
131137
}
132138
}
133139

@@ -138,10 +144,10 @@ func TestResolveExternalNodeSSH_EmptyHostname(t *testing.T) {
138144
node := &nodev1.ExternalNode{
139145
Name: "box",
140146
SshAccess: []*nodev1.SSHAccess{
141-
{UserId: "user_1", LinuxUser: "ubuntu"},
147+
{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"},
142148
},
143149
Ports: []*nodev1.Port{
144-
{Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22},
150+
{PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22},
145151
},
146152
}
147153

0 commit comments

Comments
 (0)