Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.24.0

require (
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260309172248-8105d701fdce.1
connectrpc.com/connect v1.19.1
github.com/NVIDIA/go-nvml v0.13.0-1
github.com/alessio/shellescape v1.4.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2 h1:Sq0kIa/xKzScbJcqB5EbPVhOL0QYHPr3araQaupL2lk=
buf.build/gen/go/brevdev/devplane/connectrpc/go v1.19.1-20260228021043-887d38e1b474.2/go.mod h1:Yh34p9aADmWsKv2umYlMpnCZuBmNBE9N+HImgRriJXM=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1 h1:d+kY4OSI/WV2eBuO5G7ezCQ8RiLtDOIrUbtmZOKi5Kw=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260305201249-af5106090c8a.1/go.mod h1:V/y7Wxg0QvU4XPVwqErF5NHLobUT1QEyfgrGuQIxdPo=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260309172248-8105d701fdce.1 h1:lWdcuXsXpMfPOer4yawjwomVbtSAnGgFAWYF8ggK9g4=
buf.build/gen/go/brevdev/devplane/protocolbuffers/go v1.36.11-20260309172248-8105d701fdce.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=
Expand Down
6 changes: 5 additions & 1 deletion pkg/cmd/enablessh/enablessh.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ func enableSSH(
return fmt.Errorf("SSH port: %w", err)
}

if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, u, port); err != nil {
if err := register.OpenSSHPort(ctx, t, deps.nodeClients, tokenProvider, reg, port); err != nil {
return fmt.Errorf("enable SSH failed: %w", err)
}

if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, u); err != nil {
return fmt.Errorf("enable SSH failed: %w", err)
}

Expand Down
7 changes: 1 addition & 6 deletions pkg/cmd/grantssh/grantssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,7 @@ func runGrantSSH(ctx context.Context, t *terminal.Terminal, s GrantSSHStore, dep
t.Vprintf(" Linux user: %s\n", linuxUser)
t.Vprint("")

port, err := register.PromptSSHPort(t)
if err != nil {
return fmt.Errorf("SSH port: %w", err)
}

if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, s, reg, selectedUser, osUser, port); err != nil {
if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, s, reg, selectedUser, osUser); err != nil {
return fmt.Errorf("grant SSH failed: %w", err)
}

Expand Down
17 changes: 11 additions & 6 deletions pkg/cmd/grantssh/grantssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ type fakeNodeService struct {
grantSSHFn func(*nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error)
}

func (f *fakeNodeService) OpenPort(_ context.Context, req *connect.Request[nodev1.OpenPortRequest]) (*connect.Response[nodev1.OpenPortResponse], error) {
return connect.NewResponse(&nodev1.OpenPortResponse{
Port: &nodev1.Port{
PortId: "port_ssh",
Protocol: req.Msg.GetProtocol(),
PortNumber: req.Msg.GetPortNumber(),
ServerPort: req.Msg.GetPortNumber(),
},
}), nil
}

func (f *fakeNodeService) GrantNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.GrantNodeSSHAccessRequest]) (*connect.Response[nodev1.GrantNodeSSHAccessResponse], error) {
resp, err := f.grantSSHFn(req.Msg)
if err != nil {
Expand Down Expand Up @@ -271,9 +282,6 @@ func Test_runGrantSSH_HappyPath(t *testing.T) {
deps, server := testGrantSSHDeps(t, svc, regStore)
defer server.Close()

register.SetTestSSHPort(22)
defer register.ClearTestSSHPort()

term := terminal.New()
err := runGrantSSH(context.Background(), term, store, deps)
if err != nil {
Expand Down Expand Up @@ -326,9 +334,6 @@ func Test_runGrantSSH_RPCFailure(t *testing.T) {
deps, server := testGrantSSHDeps(t, svc, regStore)
defer server.Close()

register.SetTestSSHPort(22)
defer register.ClearTestSSHPort()

term := terminal.New()
err := runGrantSSH(context.Background(), term, store, deps)
if err == nil {
Expand Down
6 changes: 5 additions & 1 deletion pkg/cmd/register/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,11 @@ func grantSSHAccess(ctx context.Context, t *terminal.Terminal, deps registerDeps
return fmt.Errorf("SSH port: %w", err)
}

err = GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, osUser, port)
if err := OpenSSHPort(ctx, t, deps.nodeClients, tokenProvider, reg, port); err != nil {
return fmt.Errorf("allocate SSH port failed: %w", err)
}

err = GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, osUser)
if err != nil {
return fmt.Errorf("grant SSH failed: %w", err)
}
Expand Down
132 changes: 132 additions & 0 deletions pkg/cmd/register/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,3 +913,135 @@ func Test_runRegister_NoNameAlreadyRegistered(t *testing.T) {
t.Error("expected registration to still exist")
}
}

func Test_runRegister_OpenSSHPort(t *testing.T) { // nolint:funlen // test
tests := []struct {
name string
port int32
openFn func(*nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error)
verify func(t *testing.T, openReq *nodev1.OpenPortRequest, grantReq *nodev1.GrantNodeSSHAccessRequest, reg *mockRegistrationStore, err error)
}{
{
name: "SendsCorrectArgs",
port: 2222,
openFn: func(req *nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) {
return &nodev1.OpenPortResponse{
Port: &nodev1.Port{
PortId: "port_ssh",
Protocol: req.GetProtocol(),
PortNumber: req.GetPortNumber(),
},
}, nil
},
verify: func(t *testing.T, openReq *nodev1.OpenPortRequest, _ *nodev1.GrantNodeSSHAccessRequest, _ *mockRegistrationStore, err error) {
t.Helper()
if err != nil {
t.Fatalf("runRegister failed: %v", err)
}
if openReq == nil {
t.Fatal("expected OpenPort to be called")
}
if openReq.GetExternalNodeId() != "unode_abc" {
t.Errorf("expected node ID unode_abc, got %s", openReq.GetExternalNodeId())
}
if openReq.GetProtocol() != nodev1.PortProtocol_PORT_PROTOCOL_SSH {
t.Errorf("expected PORT_PROTOCOL_SSH, got %s", openReq.GetProtocol())
}
if openReq.GetPortNumber() != 2222 {
t.Errorf("expected port 2222, got %d", openReq.GetPortNumber())
}
},
},
{
name: "FailureIsSoftError",
port: 22,
openFn: func(_ *nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("skybridge unavailable"))
},
verify: func(t *testing.T, _ *nodev1.OpenPortRequest, _ *nodev1.GrantNodeSSHAccessRequest, regStore *mockRegistrationStore, err error) {
t.Helper()
if err != nil {
t.Fatalf("registration should succeed even when OpenSSHPort fails (soft error), got: %v", err)
}
exists, _ := regStore.Exists()
if !exists {
t.Error("expected registration to still exist after OpenSSHPort failure")
}
},
},
{
name: "GrantRequestHasNoPort",
port: 22,
verify: func(t *testing.T, _ *nodev1.OpenPortRequest, grantReq *nodev1.GrantNodeSSHAccessRequest, _ *mockRegistrationStore, err error) {
t.Helper()
if err != nil {
t.Fatalf("runRegister failed: %v", err)
}
if grantReq == nil {
t.Fatal("expected GrantNodeSSHAccess to be called")
}
if grantReq.GetExternalNodeId() != "unode_abc" {
t.Errorf("expected node ID unode_abc, got %s", grantReq.GetExternalNodeId())
}
if grantReq.GetUserId() != "user_1" {
t.Errorf("expected user ID user_1, got %s", grantReq.GetUserId())
}
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
regStore := &mockRegistrationStore{}
store := &mockRegisterStore{
user: &entity.User{ID: "user_1"},
org: &entity.Organization{ID: "org_123", Name: "TestOrg"},
token: "tok",
}

var gotOpenReq *nodev1.OpenPortRequest
var gotGrantReq *nodev1.GrantNodeSSHAccessRequest
svc := &fakeNodeService{
addNodeFn: func(req *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) {
return &nodev1.AddNodeResponse{
ExternalNode: &nodev1.ExternalNode{
ExternalNodeId: "unode_abc",
OrganizationId: "org_123",
Name: req.GetName(),
DeviceId: req.GetDeviceId(),
ConnectivityInfo: &nodev1.ConnectivityInfo{
RegistrationCommand: "netbird up --key abc",
},
},
}, nil
},
openPortFn: func(req *nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) {
gotOpenReq = req
if tt.openFn != nil {
return tt.openFn(req)
}
return &nodev1.OpenPortResponse{
Port: &nodev1.Port{PortId: "port_ssh", Protocol: req.GetProtocol(), PortNumber: req.GetPortNumber()},
}, nil
},
grantNodeSSHAccessFn: func(req *nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) {
gotGrantReq = req
return &nodev1.GrantNodeSSHAccessResponse{}, nil
},
}

deps, server := testRegisterDeps(t, svc, regStore)
defer server.Close()

deps.prompter = mockConfirmer{confirm: true}

SetTestSSHPort(tt.port)
defer ClearTestSSHPort()

term := terminal.New()
err := runRegister(context.Background(), term, store, "my-spark", "", deps)

tt.verify(t, gotOpenReq, gotGrantReq, regStore, err)
})
}
}
19 changes: 19 additions & 0 deletions pkg/cmd/register/rpcclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ type fakeNodeService struct {
addNodeFn func(*nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error)
removeNodeFn func(*nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error)
getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error)
openPortFn func(*nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error)
grantNodeSSHAccessFn func(*nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error)
}

Expand Down Expand Up @@ -209,6 +210,24 @@ func (f *fakeNodeService) GetNode(_ context.Context, req *connect.Request[nodev1
return connect.NewResponse(resp), nil
}

func (f *fakeNodeService) OpenPort(_ context.Context, req *connect.Request[nodev1.OpenPortRequest]) (*connect.Response[nodev1.OpenPortResponse], error) {
if f.openPortFn == nil {
return connect.NewResponse(&nodev1.OpenPortResponse{
Port: &nodev1.Port{
PortId: "port_ssh",
Protocol: req.Msg.GetProtocol(),
PortNumber: req.Msg.GetPortNumber(),
ServerPort: req.Msg.GetPortNumber(),
},
}), nil
}
resp, err := f.openPortFn(req.Msg)
if err != nil {
return nil, err
}
return connect.NewResponse(resp), nil
}

func (f *fakeNodeService) GrantNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.GrantNodeSSHAccessRequest]) (*connect.Response[nodev1.GrantNodeSSHAccessResponse], error) {
if f.grantNodeSSHAccessFn == nil {
return nil, connect.NewError(connect.CodeUnimplemented, nil)
Expand Down
29 changes: 26 additions & 3 deletions pkg/cmd/register/sshkeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,34 @@ func RemoveAuthorizedKeyLine(u *user.User, line string) error {
return nil
}

// OpenSSHPort calls the OpenPort RPC to allocate an SSH port on the node.
// This must be called before GrantSSHAccessToNode when enabling SSH for the
// first time on a device. The call is idempotent — if the port is already
// open, the server returns the existing allocation.
func OpenSSHPort(
ctx context.Context,
t *terminal.Terminal,
nodeClients externalnode.NodeClientFactory,
tokenProvider externalnode.TokenProvider,
reg *DeviceRegistration,
port int32,
) error {
client := nodeClients.NewNodeClient(tokenProvider, config.GlobalConfig.GetBrevPublicAPIURL())
_, err := client.OpenPort(ctx, connect.NewRequest(&nodev1.OpenPortRequest{
ExternalNodeId: reg.ExternalNodeID,
Protocol: nodev1.PortProtocol_PORT_PROTOCOL_SSH,
PortNumber: port,
}))
if err != nil {
return fmt.Errorf("failed to allocate SSH port: %w", err)
}
t.Vprintf(" SSH port %d allocated.\n", port)
return nil
}

// GrantSSHAccessToNode installs the user's public key in authorized_keys and
// calls GrantNodeSSHAccess to record access server-side. If the RPC fails,
// the installed key is rolled back. port is the target SSH port (e.g. 22).
// the installed key is rolled back.
func GrantSSHAccessToNode(
ctx context.Context,
t *terminal.Terminal,
Expand All @@ -142,7 +167,6 @@ func GrantSSHAccessToNode(
reg *DeviceRegistration,
targetUser *entity.User,
osUser *user.User,
port int32,
) error {
if targetUser.PublicKey != "" {
if added, err := InstallAuthorizedKey(osUser, targetUser.PublicKey, targetUser.ID); err != nil {
Expand All @@ -165,7 +189,6 @@ func GrantSSHAccessToNode(
ExternalNodeId: reg.ExternalNodeID,
UserId: targetUser.ID,
LinuxUser: osUser.Username,
Port: port,
}))
if err != nil {
// Retryable error
Expand Down
Loading