diff --git a/pkg/cmd/deregister/deregister.go b/pkg/cmd/deregister/deregister.go index 06b902dd..75defcae 100644 --- a/pkg/cmd/deregister/deregister.go +++ b/pkg/cmd/deregister/deregister.go @@ -48,6 +48,7 @@ type deregisterDeps struct { platform externalnode.PlatformChecker prompter terminal.Selector netbird register.NetBirdManager + sshd register.ManagedSSHDaemon nodeClients externalnode.NodeClientFactory registrationStore register.RegistrationStore sshKeys SSHKeyRemover @@ -58,6 +59,7 @@ func defaultDeregisterDeps(brevHome string) deregisterDeps { platform: register.LinuxPlatform{}, prompter: register.TerminalPrompter{}, netbird: register.Netbird{}, + sshd: register.BrevSSHD{}, nodeClients: register.DefaultNodeClientFactory{}, registrationStore: register.NewFileRegistrationStore(brevHome), sshKeys: brevSSHKeyRemover{}, @@ -158,6 +160,15 @@ func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore, } t.Vprint("") + // Remove brev-managed sshd (non-fatal on failure). + t.Vprint("Removing managed SSH daemon...") + if err := deps.sshd.Uninstall(); err != nil { + t.Vprintf(" Warning: failed to remove managed SSH daemon: %v\n", err) + } else { + t.Vprint(t.Green(" Managed SSH daemon removed.")) + } + t.Vprint("") + t.Vprint("Removing Brev tunnel...") if err := deps.netbird.Uninstall(); err != nil { t.Vprintf(" Warning: failed to remove Brev tunnel: %v\n", err) diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index b901c9ff..2043febd 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -96,6 +96,17 @@ type mockNetBirdManager struct { func (m *mockNetBirdManager) Install() error { return m.err } func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err } +type mockManagedSSHDaemon struct { + uninstallCalled bool + uninstallErr error +} + +func (m *mockManagedSSHDaemon) Install() error { return nil } +func (m *mockManagedSSHDaemon) Uninstall() error { + m.uninstallCalled = true + return m.uninstallErr +} + type mockNodeClientFactory struct { serverURL string } @@ -133,6 +144,7 @@ func testDeregisterDeps(t *testing.T, svc *fakeNodeService, regStore register.Re return "" }}, netbird: &mockNetBirdManager{}, + sshd: &mockManagedSSHDaemon{}, nodeClients: mockNodeClientFactory{serverURL: server.URL}, registrationStore: regStore, sshKeys: &mockSSHKeyRemover{}, @@ -379,3 +391,46 @@ func Test_runDeregister_RemoveBrevKeysHandling(t *testing.T) { }) } } + +func Test_runDeregister_SSHDUninstallFailureIsNonFatal(t *testing.T) { + regStore := &mockRegistrationStore{ + reg: ®ister.DeviceRegistration{ + ExternalNodeID: "unode_abc", + DisplayName: "My Spark", + OrgID: "org_123", + }, + } + + store := &mockDeregisterStore{ + user: &entity.User{ID: "user_1"}, + home: "/home/testuser/.brev", + token: "tok", + } + + svc := &fakeNodeService{ + removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { + return &nodev1.RemoveNodeResponse{}, nil + }, + } + + sshdMock := &mockManagedSSHDaemon{uninstallErr: fmt.Errorf("permission denied")} + deps, server := testDeregisterDeps(t, svc, regStore) + defer server.Close() + deps.sshd = sshdMock + + term := terminal.New() + err := runDeregister(context.Background(), term, store, deps) + if err != nil { + t.Fatalf("expected nil error (sshd failure should be non-fatal), got: %v", err) + } + + if !sshdMock.uninstallCalled { + t.Error("expected sshd Uninstall to be called") + } + + // Registration should still be cleaned up despite sshd failure. + exists, _ := regStore.Exists() + if exists { + t.Error("expected registration to be deleted") + } +} diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index cabfa1c4..07531e37 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -38,6 +38,12 @@ type Netbird struct{} func (Netbird) Install() error { return InstallNetbird() } func (Netbird) Uninstall() error { return UninstallNetbird() } +// BrevSSHD manages the brev-managed sshd instance on port 2222. +type BrevSSHD struct{} + +func (BrevSSHD) Install() error { return InstallBrevSSHD() } +func (BrevSSHD) Uninstall() error { return UninstallBrevSSHD() } + // ShellSetupRunner runs setup scripts via shell. type ShellSetupRunner struct{} diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index e56c0e6c..396e49e5 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -48,6 +48,12 @@ type NetBirdManager interface { Uninstall() error } +// ManagedSSHDaemon installs and uninstalls a brev-managed sshd instance. +type ManagedSSHDaemon interface { + Install() error + Uninstall() error +} + // SetupRunner runs a setup script on the local machine. type SetupRunner interface { RunSetup(script string) error @@ -59,6 +65,7 @@ type registerDeps struct { platform externalnode.PlatformChecker prompter terminal.Confirmer netbird NetBirdManager + sshd ManagedSSHDaemon setupRunner SetupRunner nodeClients externalnode.NodeClientFactory commandRunner CommandRunner @@ -71,6 +78,7 @@ func defaultRegisterDeps(brevHome string) registerDeps { platform: LinuxPlatform{}, prompter: TerminalPrompter{}, netbird: Netbird{}, + sshd: BrevSSHD{}, setupRunner: ShellSetupRunner{}, nodeClients: DefaultNodeClientFactory{}, commandRunner: ExecCommandRunner{}, @@ -147,8 +155,9 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam t.Vprint("") t.Vprint("This will perform the following steps:") t.Vprint(" 1. Set up Brev tunnel") - t.Vprint(" 2. Collect hardware profile") - t.Vprint(" 3. Register this machine with Brev") + t.Vprint(" 2. Set up managed SSH daemon (port 2222)") + t.Vprint(" 3. Collect hardware profile") + t.Vprint(" 4. Register this machine with Brev") t.Vprint("") if !deps.prompter.ConfirmYesNo("Proceed with registration?") { @@ -157,14 +166,21 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam } t.Vprint("") - t.Vprint(t.Yellow("[Step 1/3] Setting up Brev tunnel...")) + t.Vprint(t.Yellow("[Step 1/4] Setting up Brev tunnel...")) if err := deps.netbird.Install(); err != nil { return fmt.Errorf("brev tunnel setup failed: %w", err) } t.Vprint(t.Green(" Brev tunnel ready.")) t.Vprint("") - t.Vprint(t.Yellow("[Step 2/3] Collecting hardware profile...")) + t.Vprint(t.Yellow("[Step 2/4] Setting up managed SSH daemon...")) + if err := deps.sshd.Install(); err != nil { + return fmt.Errorf("managed sshd setup failed: %w", err) + } + t.Vprint(t.Green(" Managed SSH daemon ready (port 2222).")) + + t.Vprint("") + t.Vprint(t.Yellow("[Step 3/4] Collecting hardware profile...")) t.Vprint("") nodeSpec, err := CollectHardwareProfile(deps.commandRunner, deps.fileReader) @@ -176,7 +192,7 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam t.Vprint(FormatNodeSpec(nodeSpec)) t.Vprint("") - t.Vprint(t.Yellow("[Step 3/3] Registering with Brev...")) + t.Vprint(t.Yellow("[Step 4/4] Registering with Brev...")) deviceID := uuid.New().String() client := deps.nodeClients.NewNodeClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 5aad1b80..abc8cdbb 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -80,6 +80,23 @@ type mockNetBirdManager struct{ err error } func (m mockNetBirdManager) Install() error { return m.err } func (m mockNetBirdManager) Uninstall() error { return m.err } +type mockManagedSSHDaemon struct { + installCalled bool + uninstallCalled bool + installErr error + uninstallErr error +} + +func (m *mockManagedSSHDaemon) Install() error { + m.installCalled = true + return m.installErr +} + +func (m *mockManagedSSHDaemon) Uninstall() error { + m.uninstallCalled = true + return m.uninstallErr +} + type mockSetupRunner struct { called bool cmd string @@ -112,6 +129,7 @@ func testRegisterDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationS platform: mockPlatform{compatible: true}, prompter: mockConfirmer{confirm: true}, netbird: mockNetBirdManager{}, + sshd: &mockManagedSSHDaemon{}, setupRunner: &mockSetupRunner{}, nodeClients: mockNodeClientFactory{serverURL: server.URL}, commandRunner: &mockCommandRunner{ @@ -419,6 +437,37 @@ func Test_runRegister_NoSetupCommand(t *testing.T) { } } +func Test_runRegister_SSHDInstallFailAbortsRegistration(t *testing.T) { + regStore := &mockRegistrationStore{} + + store := &mockRegisterStore{ + user: &entity.User{ID: "user_1"}, + org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, + home: "/home/testuser/.brev", + token: "tok", + } + + svc := &fakeNodeService{} + deps, server := testRegisterDeps(t, svc, regStore) + defer server.Close() + + deps.sshd = &mockManagedSSHDaemon{installErr: fmt.Errorf("permission denied")} + + term := terminal.New() + err := runRegister(context.Background(), term, store, "My Spark", deps) + if err == nil { + t.Fatal("expected error when sshd install fails") + } + if !strings.Contains(err.Error(), "managed sshd setup failed") { + t.Errorf("unexpected error message: %v", err) + } + + exists, _ := regStore.Exists() + if exists { + t.Error("registration should not exist after sshd install failure") + } +} + func Test_runSetupCommand_Validation(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/register/sshd.go b/pkg/cmd/register/sshd.go new file mode 100644 index 00000000..bd39f866 --- /dev/null +++ b/pkg/cmd/register/sshd.go @@ -0,0 +1,136 @@ +package register + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const ( + brevSSHDDir = "/etc/brev-sshd" + brevSSHDConfigPath = "/etc/brev-sshd/sshd_config" + brevSSHDUnitPath = "/etc/systemd/system/brev-sshd.service" + brevSSHDHostKey = "/etc/brev-sshd/ssh_host_ed25519_key" + brevSSHDBinary = "/usr/sbin/sshd" +) + +// sshdConfig is the hardened sshd_config for the brev-managed sshd on port 2222. +const sshdConfig = `# Brev-managed sshd configuration +# Do not edit — managed by brev register/deregister + +# Non-standard port to avoid conflicting with the system sshd on port 22. +Port 2222 + +# Isolated ed25519 host key in brev's own directory for clean install/uninstall. +HostKey /etc/brev-sshd/ssh_host_ed25519_key + +# Key-only authentication — brev manages keys via sshkeys.go. +PubkeyAuthentication yes +# Disable password auth to prevent brute-force attacks. +PasswordAuthentication no +# Disable PAM to ensure it can't re-enable password or keyboard-interactive auth. +UsePAM no +# Allow root login only via public key, never password. +PermitRootLogin prohibit-password +# Limit auth attempts per connection (default 6) to reduce brute-force window. +MaxAuthTries 3 +# Disconnect unauthenticated sessions after 30s (default 120) to limit resource waste. +LoginGraceTime 30 + +# Reuse the same authorized_keys managed by sshkeys.go — no separate key store needed. +AuthorizedKeysFile %h/.ssh/authorized_keys + +# Modern AEAD ciphers only; excludes legacy CBC and non-AEAD modes. +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com +# Post-quantum hybrid KEX + standard curve25519; excludes weak DH groups. +KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256 +# Encrypt-then-MAC variants only; stronger than MAC-then-encrypt. +MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com + +# Dedicated PID file to avoid collision with system sshd's /run/sshd.pid. +PidFile /run/brev-sshd.pid +` + +// sshdUnit is the systemd unit file for the brev-managed sshd. +// ExecStartPre validates the config before starting (fail-fast on typos). +// ExecStart runs sshd in foreground mode (-D) so systemd can supervise it. +// Restart=on-failure auto-recovers from crashes without restarting on clean exit. +const sshdUnit = `[Unit] +Description=Brev SSH Daemon (port 2222) +After=network.target + +[Service] +Type=simple +ExecStartPre=/usr/sbin/sshd -t -f /etc/brev-sshd/sshd_config +ExecStart=/usr/sbin/sshd -D -f /etc/brev-sshd/sshd_config +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +` + +// InstallBrevSSHD sets up the brev-managed sshd on port 2222. +// It creates the config directory, generates a host key (idempotent), +// writes the sshd_config and systemd unit, then enables and starts the service. +func InstallBrevSSHD() error { + // Create config directory + if err := os.MkdirAll(brevSSHDDir, 0o755); err != nil { + return fmt.Errorf("creating brev-sshd directory: %w", err) + } + + // Generate ed25519 host key if it doesn't exist + if _, err := os.Stat(brevSSHDHostKey); os.IsNotExist(err) { + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", brevSSHDHostKey, "-N", "") // #nosec G204 + if err := cmd.Run(); err != nil { + return fmt.Errorf("generating host key: %w", err) + } + } + + // Write sshd_config + if err := os.WriteFile(brevSSHDConfigPath, []byte(sshdConfig), 0o644); err != nil { + return fmt.Errorf("writing sshd_config: %w", err) + } + + // Write systemd unit + if err := os.WriteFile(brevSSHDUnitPath, []byte(sshdUnit), 0o644); err != nil { + return fmt.Errorf("writing systemd unit: %w", err) + } + + // Reload systemd, enable and start the service + script := `sudo systemctl daemon-reload && sudo systemctl enable brev-sshd.service && sudo systemctl start brev-sshd.service` + cmd := exec.Command("bash", "-c", script) // #nosec G204 + if err := cmd.Run(); err != nil { + return fmt.Errorf("enabling brev-sshd service: %w", err) + } + + return nil +} + +// UninstallBrevSSHD stops and removes the brev-managed sshd service and its +// configuration. Errors are best-effort — the function attempts all cleanup +// steps even if individual steps fail. +func UninstallBrevSSHD() error { + // Stop and disable the service (best-effort) + _ = exec.Command("bash", "-c", "sudo systemctl stop brev-sshd.service 2>/dev/null").Run() // #nosec G204 + _ = exec.Command("bash", "-c", "sudo systemctl disable brev-sshd.service 2>/dev/null").Run() // #nosec G204 + + // Remove systemd unit file + if err := os.Remove(brevSSHDUnitPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing systemd unit: %w", err) + } + + // Reload systemd + _ = exec.Command("bash", "-c", "sudo systemctl daemon-reload").Run() // #nosec G204 + + // Remove config directory + if err := os.RemoveAll(brevSSHDDir); err != nil { + return fmt.Errorf("removing brev-sshd directory: %w", err) + } + + // Remove PID file if leftover + _ = os.Remove(filepath.Join("/run", "brev-sshd.pid")) + + return nil +} diff --git a/pkg/cmd/register/sshd_test.go b/pkg/cmd/register/sshd_test.go new file mode 100644 index 00000000..40f2445f --- /dev/null +++ b/pkg/cmd/register/sshd_test.go @@ -0,0 +1,65 @@ +package register + +import ( + "strings" + "testing" +) + +func Test_sshdConfig_Content(t *testing.T) { + checks := []struct { + name string + want string + }{ + {"Port", "Port 2222"}, + {"HostKey", "HostKey /etc/brev-sshd/ssh_host_ed25519_key"}, + {"PubkeyAuth", "PubkeyAuthentication yes"}, + {"PasswordAuth", "PasswordAuthentication no"}, + {"UsePAM", "UsePAM no"}, + {"PermitRootLogin", "PermitRootLogin prohibit-password"}, + {"MaxAuthTries", "MaxAuthTries 3"}, + {"LoginGraceTime", "LoginGraceTime 30"}, + {"AuthorizedKeysFile", "AuthorizedKeysFile %h/.ssh/authorized_keys"}, + {"PidFile", "PidFile /run/brev-sshd.pid"}, + {"Chacha20Cipher", "chacha20-poly1305@openssh.com"}, + {"AES256GCM", "aes256-gcm@openssh.com"}, + {"AES128GCM", "aes128-gcm@openssh.com"}, + {"Sntrup761KEX", "sntrup761x25519-sha512@openssh.com"}, + {"Curve25519KEX", "curve25519-sha256"}, + {"HMACSHA256ETM", "hmac-sha2-256-etm@openssh.com"}, + {"HMACSHA512ETM", "hmac-sha2-512-etm@openssh.com"}, + } + + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + if !strings.Contains(sshdConfig, tc.want) { + t.Errorf("sshd_config missing %q", tc.want) + } + }) + } +} + +func Test_sshdUnit_Content(t *testing.T) { + checks := []struct { + name string + want string + }{ + {"Description", "Description=Brev SSH Daemon (port 2222)"}, + {"ExecStartPre", "ExecStartPre=/usr/sbin/sshd -t -f /etc/brev-sshd/sshd_config"}, + {"ExecStart", "ExecStart=/usr/sbin/sshd -D -f /etc/brev-sshd/sshd_config"}, + {"Restart", "Restart=on-failure"}, + {"WantedBy", "WantedBy=multi-user.target"}, + } + + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + if !strings.Contains(sshdUnit, tc.want) { + t.Errorf("systemd unit missing %q", tc.want) + } + }) + } +} + +func Test_BrevSSHD_ImplementsInterface(t *testing.T) { + // Compile-time check that BrevSSHD satisfies ManagedSSHDaemon. + var _ ManagedSSHDaemon = BrevSSHD{} +}