diff --git a/go.mod b/go.mod index bd3acbbf..27a158b6 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/brevdev/brev-cli go 1.25.0 require ( - buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1 - buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1 - connectrpc.com/connect v1.19.2 + buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1 + buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1 + connectrpc.com/connect v1.20.0 github.com/NVIDIA/go-nvml v0.13.0-1 github.com/alessio/shellescape v1.4.1 github.com/brevdev/parse v0.0.11 diff --git a/go.sum b/go.sum index ad346e93..ea417b60 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1 h1:OtdZWOk/dypzAe4bylO+TFfcw9J3Ndyeh1yylWSNgRc= -buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.2-20260520183101-9f4cb67aff2c.1/go.mod h1:eaa0R5ozu4wxcy62DEtRxO6hahJ0WuFsMAG33Zj/lVQ= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1 h1:fDUuYv/K3h8IpEGf0uic/1/A1nBN+Vao4jzVWDRMLLc= -buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260520183101-9f4cb67aff2c.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1 h1:p2gDnCmIeMzMuRNP05Jh143Q8iiSq0/oXG8eckzCkSY= +buf.build/gen/go/brevdev/devplane/connectrpc/go v1.20.0-20260521231113-5bd61a2e035f.1/go.mod h1:CwGL+2J9G36DvGlMYW/5f+LTnGAOGJPcAw3S/Zy7lbk= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1 h1:NyJ55L5BmM+AOC77hUrLysVvzU4m9YO+g93YwvZS3Y4= +buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260521231113-5bd61a2e035f.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1 h1:6amhprQmCKJ4wgJ6ngkh32d9V+dQcOLUZ/SfHdOnYgo= buf.build/gen/go/brevdev/protoc-gen-gotag/protocolbuffers/go v1.36.11-20220906235457-8b4922735da5.1/go.mod h1:O+pnSHMru/naTMrm4tmpBoH3wz6PHa+R75HR7Mv8X2g= 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 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= -connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= -connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= diff --git a/pkg/cmd/refresh/refresh_test.go b/pkg/cmd/refresh/refresh_test.go index 7c53b598..a35cb472 100644 --- a/pkg/cmd/refresh/refresh_test.go +++ b/pkg/cmd/refresh/refresh_test.go @@ -14,11 +14,12 @@ func TestResolveNodeSSHEntry_HappyPath(t *testing.T) { node := &nodev1.ExternalNode{ Name: "My GPU Box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ec2-user"}, + {UserId: "user_1", LinuxUser: "ec2-user", PortId: "port_1"}, }, Ports: []*nodev1.Port{ { - Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, + PortId: "port_1", + Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 41920, ServerPort: 22, Hostname: strPtr("10.0.0.5"), @@ -48,11 +49,12 @@ func TestResolveNodeSSHEntry_UsesServerPortNotPortNumber(t *testing.T) { node := &nodev1.ExternalNode{ Name: "test-node", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, }, Ports: []*nodev1.Port{ { - Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, + PortId: "port_1", + Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 51234, // netbird-assigned port — correct ServerPort: 22, // well-known port — NOT what we should connect to Hostname: strPtr("gateway.example.com"), @@ -76,7 +78,7 @@ func TestResolveNodeSSHEntry_SkipsNoAccess(t *testing.T) { {UserId: "other_user", LinuxUser: "ubuntu"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22, Hostname: strPtr("h")}, + {PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22, Hostname: strPtr("h")}, }, } @@ -86,20 +88,23 @@ func TestResolveNodeSSHEntry_SkipsNoAccess(t *testing.T) { } } -func TestResolveNodeSSHEntry_SkipsNoSSHPort(t *testing.T) { +func TestResolveNodeSSHEntry_UsesAccessPortNotProtocol(t *testing.T) { node := &nodev1.ExternalNode{ Name: "box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_tcp"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 8080, Hostname: strPtr("h")}, + {PortId: "port_tcp", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 51234, ServerPort: 22, Hostname: strPtr("h")}, }, } entry := util.ResolveNodeSSHEntry("user_1", node) - if entry != nil { - t.Errorf("expected nil for no SSH port, got %+v", entry) + if entry == nil { + t.Fatal("expected entry for TCP port with access") + } + if entry.Port != 51234 { + t.Errorf("expected port 51234, got %d", entry.Port) } } @@ -107,10 +112,10 @@ func TestResolveNodeSSHEntry_SkipsEmptyHostname(t *testing.T) { node := &nodev1.ExternalNode{ Name: "box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22}, + {PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22}, }, } @@ -120,33 +125,50 @@ func TestResolveNodeSSHEntry_SkipsEmptyHostname(t *testing.T) { } } +func TestResolveNodeSSHEntry_SkipsWhenPortIDMissing(t *testing.T) { + node := &nodev1.ExternalNode{ + Name: "box", + SshAccess: []*nodev1.SSHAccess{ + {UserId: "user_1", LinuxUser: "ubuntu"}, + }, + Ports: []*nodev1.Port{ + {PortId: "port_a", PortNumber: 41000, ServerPort: 22, Hostname: strPtr("10.0.0.1")}, + }, + } + + entry := util.ResolveNodeSSHEntry("user_1", node) + if entry != nil { + t.Errorf("expected nil without PortId on access, got %+v", entry) + } +} + func TestResolveNodeSSHEntry_MultipleNodes(t *testing.T) { nodes := []*nodev1.ExternalNode{ { Name: "Node A", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_a"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 41000, Hostname: strPtr("10.0.0.1")}, + {PortId: "port_a", PortNumber: 41000, ServerPort: 22, Hostname: strPtr("10.0.0.1")}, }, }, { Name: "Node B", SshAccess: []*nodev1.SSHAccess{ - {UserId: "other_user", LinuxUser: "root"}, + {UserId: "other_user", LinuxUser: "root", PortId: "port_b"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 42000, Hostname: strPtr("10.0.0.2")}, + {PortId: "port_b", PortNumber: 42000, ServerPort: 22, Hostname: strPtr("10.0.0.2")}, }, }, { Name: "Node C", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "admin"}, + {UserId: "user_1", LinuxUser: "admin", PortId: "port_c"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 43000, Hostname: strPtr("10.0.0.3")}, + {PortId: "port_c", PortNumber: 43000, ServerPort: 22, Hostname: strPtr("10.0.0.3")}, }, }, } diff --git a/pkg/cmd/shell/shell_test.go b/pkg/cmd/shell/shell_test.go index 991ea9c4..f8d89f91 100644 --- a/pkg/cmd/shell/shell_test.go +++ b/pkg/cmd/shell/shell_test.go @@ -17,11 +17,12 @@ func TestResolveExternalNodeSSH_BuildsCorrectInfo(t *testing.T) { node := &nodev1.ExternalNode{ Name: "My GPU Box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ec2-user"}, + {UserId: "user_1", LinuxUser: "ec2-user", PortId: "port_1"}, }, Ports: []*nodev1.Port{ { - Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, + PortId: "port_1", + Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 41920, ServerPort: 22, Hostname: strPtr("10.0.0.5"), @@ -82,21 +83,18 @@ func TestResolveExternalNodeSSH_NoAccess(t *testing.T) { } } -// TestResolveExternalNodeSSH_NoSSHPort verifies that a node with no SSH port -// returns nil even when the user has access. -func TestResolveExternalNodeSSH_NoSSHPort(t *testing.T) { +// TestResolveExternalNodeSSH_NoPorts verifies nil when the user has access but no ports exist. +func TestResolveExternalNodeSSH_NoPorts(t *testing.T) { node := &nodev1.ExternalNode{ - Name: "no-ssh", + Name: "no-ports", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, - }, - Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 8080, Hostname: strPtr("10.0.0.1")}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_missing"}, }, + Ports: nil, } entry := util.ResolveNodeSSHEntry("user_1", node) if entry != nil { - t.Errorf("expected nil for node without SSH port, got %+v", entry) + t.Errorf("expected nil for node without ports, got %+v", entry) } } diff --git a/pkg/cmd/util/externalnode.go b/pkg/cmd/util/externalnode.go index dbe8e93f..ef78d8e4 100644 --- a/pkg/cmd/util/externalnode.go +++ b/pkg/cmd/util/externalnode.go @@ -72,38 +72,41 @@ func (info *ExternalNodeSSHInfo) HomePath() string { } // ResolveNodeSSHEntry is a pure data function that extracts the SSH config entry -// for a given user from a node. Returns nil if the user has no access or the node -// has no SSH port. This is the single source of truth for node→SSHEntry conversion, -// used by both ResolveExternalNodeSSH (for commands) and refresh (for SSH config generation). +// for a given user from a node. Returns nil if the user has no SSH access or no +// resolvable port with a hostname. Uses the port matching the user's access PortId. func ResolveNodeSSHEntry(userID string, node *nodev1.ExternalNode) *ssh.ExternalNodeSSHEntry { - var linuxUser string - for _, access := range node.GetSshAccess() { - if access.GetUserId() == userID { - linuxUser = access.GetLinuxUser() + var access *nodev1.SSHAccess + for _, a := range node.GetSshAccess() { + if a.GetUserId() == userID { + access = a break } } - if linuxUser == "" { + if access == nil || access.GetLinuxUser() == "" { return nil } - var sshPort *nodev1.Port - for _, p := range node.GetPorts() { - if p.GetProtocol() == nodev1.PortProtocol_PORT_PROTOCOL_SSH { - sshPort = p - break - } - } - if sshPort == nil || sshPort.GetHostname() == "" { + port := resolvePortForSSHAccess(node, access) + if port == nil || port.GetHostname() == "" { return nil } return &ssh.ExternalNodeSSHEntry{ Alias: ssh.SanitizeNodeName(node.GetName()), - Hostname: sshPort.GetHostname(), - Port: sshPort.GetPortNumber(), - User: linuxUser, + Hostname: port.GetHostname(), + Port: port.GetPortNumber(), + User: access.GetLinuxUser(), + } +} + +func resolvePortForSSHAccess(node *nodev1.ExternalNode, access *nodev1.SSHAccess) *nodev1.Port { + portID := access.GetPortId() + for _, p := range node.GetPorts() { + if p.GetPortId() == portID { + return p + } } + return nil } // 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 } // ResolveExternalNodeSSH resolves the SSH connection details for an external node -// by finding the current user's SSH access and the node's SSH port. +// by finding the current user's SSH access and the allocated port for that access. func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode) (*ExternalNodeSSHInfo, error) { user, err := store.GetCurrentUser() if err != nil { @@ -153,7 +156,7 @@ func ResolveExternalNodeSSH(store ExternalNodeStore, node *nodev1.ExternalNode) entry := ResolveNodeSSHEntry(user.ID, node) if entry == nil { - return nil, breverrors.New(fmt.Sprintf("cannot resolve SSH for node %q — no access, no SSH port, or no hostname", node.GetName())) + return nil, breverrors.New(fmt.Sprintf("cannot resolve SSH for node %q — no access, no port, or no hostname", node.GetName())) } return &ExternalNodeSSHInfo{ diff --git a/pkg/cmd/util/externalnode_test.go b/pkg/cmd/util/externalnode_test.go index 667972d6..9e5244ae 100644 --- a/pkg/cmd/util/externalnode_test.go +++ b/pkg/cmd/util/externalnode_test.go @@ -33,15 +33,17 @@ func (m *mockExternalNodeStore) GetCurrentUser() (*entity.User, error) { func strPtr(s string) *string { return &s } func makeTestNode(name, userID, linuxUser, hostname string, portNumber int32) *nodev1.ExternalNode { + portID := "port_test" return &nodev1.ExternalNode{ ExternalNodeId: "unode_test", Name: name, SshAccess: []*nodev1.SSHAccess{ - {UserId: userID, LinuxUser: linuxUser}, + {UserId: userID, LinuxUser: linuxUser, PortId: portID}, }, Ports: []*nodev1.Port{ { - Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, + PortId: portID, + Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: portNumber, ServerPort: 22, Hostname: &hostname, @@ -78,11 +80,12 @@ func TestResolveExternalNodeSSH_UsesServerPortNotPortNumber(t *testing.T) { node := &nodev1.ExternalNode{ Name: "test-node", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, }, Ports: []*nodev1.Port{ { - Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, + PortId: "port_1", + Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 41920, // netbird-assigned port — this is correct ServerPort: 22, // well-known port — NOT what we connect to Hostname: strPtr("gateway.example.com"), @@ -111,23 +114,26 @@ func TestResolveExternalNodeSSH_NoAccess(t *testing.T) { } } -func TestResolveExternalNodeSSH_NoSSHPort(t *testing.T) { +func TestResolveExternalNodeSSH_UsesAccessPortNotProtocol(t *testing.T) { store := &mockExternalNodeStore{ user: &entity.User{ID: "user_1"}, } node := &nodev1.ExternalNode{ Name: "box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_tcp"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, Hostname: strPtr("h")}, + {PortId: "port_tcp", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, PortNumber: 51234, ServerPort: 22, Hostname: strPtr("h")}, }, } - _, err := ResolveExternalNodeSSH(store, node) - if err == nil { - t.Fatal("expected error for no SSH port") + info, err := ResolveExternalNodeSSH(store, node) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Port != 51234 { + t.Errorf("expected port 51234, got %d", info.Port) } } @@ -138,10 +144,10 @@ func TestResolveExternalNodeSSH_EmptyHostname(t *testing.T) { node := &nodev1.ExternalNode{ Name: "box", SshAccess: []*nodev1.SSHAccess{ - {UserId: "user_1", LinuxUser: "ubuntu"}, + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, }, Ports: []*nodev1.Port{ - {Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH, ServerPort: 22}, + {PortId: "port_1", Protocol: nodev1.PortProtocol_PORT_PROTOCOL_TCP, ServerPort: 22}, }, }