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/enablessh/enablessh.go b/pkg/cmd/enablessh/enablessh.go index dac16bc8..f10c6a08 100644 --- a/pkg/cmd/enablessh/enablessh.go +++ b/pkg/cmd/enablessh/enablessh.go @@ -7,6 +7,7 @@ import ( "fmt" "os/exec" "os/user" + "strings" "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/entity" @@ -28,6 +29,7 @@ type EnableSSHStore interface { // can be replaced in tests. type enableSSHDeps struct { platform externalnode.PlatformChecker + sshd register.ManagedSSHDaemon nodeClients externalnode.NodeClientFactory registrationStore register.RegistrationStore } @@ -35,6 +37,7 @@ type enableSSHDeps struct { func defaultEnableSSHDeps(brevHome string) enableSSHDeps { return enableSSHDeps{ platform: register.LinuxPlatform{}, + sshd: register.BrevSSHD{}, nodeClients: register.DefaultNodeClientFactory{}, registrationStore: register.NewFileRegistrationStore(brevHome), } @@ -83,15 +86,15 @@ func runEnableSSH(ctx context.Context, t *terminal.Terminal, s EnableSSHStore, d return breverrors.WrapAndTrace(err) } - return enableSSH(ctx, t, deps.nodeClients, s, reg, brevUser) + return enableSSH(ctx, t, deps, s, reg, brevUser) } // enableSSH grants SSH access to the given node for the current Brev user. -// This is the "reflexive grant" — granting yourself SSH access to the device. +// It ensures the managed sshd is installed before granting access. func enableSSH( ctx context.Context, t *terminal.Terminal, - nodeClients externalnode.NodeClientFactory, + deps enableSSHDeps, tokenProvider externalnode.TokenProvider, reg *register.DeviceRegistration, brevUser *entity.User, @@ -101,8 +104,6 @@ func enableSSH( return fmt.Errorf("failed to determine current Linux user: %w", err) } - checkSSHDaemon(t) - t.Vprint("") t.Vprint(t.Green("Enabling SSH access on this device")) t.Vprint("") @@ -111,7 +112,19 @@ func enableSSH( t.Vprintf(" Linux user: %s\n", u.Username) t.Vprint("") - if err := register.GrantSSHAccessToNode(ctx, t, nodeClients, tokenProvider, reg, brevUser, u); err != nil { + if !brevSSHDRunning() { + t.Vprint("This will:") + t.Vprint(" 1. Install or upgrade openssh-server") + t.Vprint(" 2. Set up a secure SSH server on port 2222") + t.Vprint("") + 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("") + } + + if err := register.GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, u); err != nil { return fmt.Errorf("enable SSH failed: %w", err) } @@ -119,14 +132,8 @@ func enableSSH( return nil } -// checkSSHDaemon prints a warning if neither "ssh" nor "sshd" systemd services -// appear to be active. It never returns an error — it is best-effort. -func checkSSHDaemon(t *terminal.Terminal) { - for _, svc := range []string{"ssh", "sshd"} { - out, err := exec.Command("systemctl", "is-active", svc).Output() //nolint:gosec // fixed service names - if err == nil && len(out) > 0 && string(out[:len(out)-1]) == "active" { - return - } - } - t.Vprintf(" %s\n", t.Yellow("Warning: SSH daemon does not appear to be running. SSH access may not work until sshd is started.")) +// brevSSHDRunning returns true if the brev-sshd systemd service is active. +func brevSSHDRunning() bool { + out, err := exec.Command("systemctl", "is-active", "brev-sshd").Output() //nolint:gosec // fixed service name + return err == nil && strings.TrimSpace(string(out)) == "active" } 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..9244464e 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 @@ -58,8 +64,8 @@ type SetupRunner interface { type registerDeps struct { platform externalnode.PlatformChecker prompter terminal.Confirmer - netbird NetBirdManager - setupRunner SetupRunner + netbird NetBirdManager + setupRunner SetupRunner nodeClients externalnode.NodeClientFactory commandRunner CommandRunner fileReader FileReader @@ -70,8 +76,8 @@ func defaultRegisterDeps(brevHome string) registerDeps { return registerDeps{ platform: LinuxPlatform{}, prompter: TerminalPrompter{}, - netbird: Netbird{}, - setupRunner: ShellSetupRunner{}, + netbird: Netbird{}, + setupRunner: ShellSetupRunner{}, nodeClients: DefaultNodeClientFactory{}, commandRunner: ExecCommandRunner{}, fileReader: OSFileReader{}, @@ -207,6 +213,12 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, nam runSetup(node, t, deps) + t.Vprint("") + t.Vprint("SSH access allows you to connect to this device remotely.") + t.Vprint("This will:") + t.Vprint(" 1. Install or upgrade openssh-server") + t.Vprint(" 2. Set up a secure SSH server on port 2222") + t.Vprint("") if deps.prompter.ConfirmYesNo("Would you like to enable SSH access to this device?") { grantSSHAccess(ctx, t, deps, s, reg, brevUser, osUser) } @@ -339,6 +351,14 @@ func grantSSHAccess(ctx context.Context, t *terminal.Terminal, deps registerDeps t.Vprintf(" Linux user: %s\n", osUser.Username) t.Vprint("") + t.Vprint("Setting up managed SSH daemon...") + if err := InstallBrevSSHD(); err != nil { + t.Vprintf(" Warning: managed sshd setup failed: %v\n", err) + } else { + t.Vprint(t.Green(" Managed SSH daemon ready (port 2222).")) + } + t.Vprint("") + err := GrantSSHAccessToNode(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, osUser) if err != nil { t.Vprint(" Retrying in 3 seconds...") diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 5aad1b80..1d9db5e8 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -80,6 +80,7 @@ type mockNetBirdManager struct{ err error } func (m mockNetBirdManager) Install() error { return m.err } func (m mockNetBirdManager) Uninstall() error { return m.err } + type mockSetupRunner struct { called bool cmd string @@ -419,6 +420,7 @@ func Test_runRegister_NoSetupCommand(t *testing.T) { } } + 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..ec8d4af2 --- /dev/null +++ b/pkg/cmd/register/sshd.go @@ -0,0 +1,242 @@ +package register + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +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" +) + +// 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 { + // Install openssh-server if sshd binary is not present. + if err := ensureSSHDInstalled(); err != nil { + return fmt.Errorf("installing openssh-server: %w", err) + } + + // 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. + // Each step is run separately so failures produce actionable messages. + for _, args := range [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "brev-sshd.service"}, + {"systemctl", "start", "brev-sshd.service"}, + } { + cmd := exec.Command("sudo", args...) // #nosec G204 + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("running 'sudo %s': %w", strings.Join(args, " "), 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 +} + +// AddAllowedUser appends the given username to the AllowUsers directive in the +// brev-managed sshd_config. If AllowUsers does not yet exist, it is created. +// Idempotent: if the user is already listed, this is a no-op. +func AddAllowedUser(username string) error { + return addAllowedUser(brevSSHDConfigPath, username) +} + +func addAllowedUser(configPath, username string) error { + data, err := os.ReadFile(configPath) // #nosec G304 + if err != nil { + return fmt.Errorf("reading sshd_config: %w", err) + } + + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if !strings.HasPrefix(line, "AllowUsers ") { + continue + } + // AllowUsers line exists — check if user is already listed. + users := strings.Fields(line)[1:] + for _, u := range users { + if u == username { + return nil // already allowed + } + } + lines[i] = line + " " + username + return os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), 0o644) + } + + // No AllowUsers line — append one. + lines = append(lines, "AllowUsers "+username) + return os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), 0o644) +} + +// RemoveAllowedUser removes the given username from the AllowUsers directive. +// If removing the user leaves AllowUsers empty, the directive is removed entirely. +// No-op if the user is not listed or AllowUsers does not exist. +func RemoveAllowedUser(username string) error { + return removeAllowedUser(brevSSHDConfigPath, username) +} + +func removeAllowedUser(configPath, username string) error { + data, err := os.ReadFile(configPath) // #nosec G304 + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading sshd_config: %w", err) + } + + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if !strings.HasPrefix(line, "AllowUsers ") { + continue + } + users := strings.Fields(line)[1:] + var kept []string + for _, u := range users { + if u != username { + kept = append(kept, u) + } + } + if len(kept) == 0 { + lines = append(lines[:i], lines[i+1:]...) + } else { + lines[i] = "AllowUsers " + strings.Join(kept, " ") + } + return os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), 0o644) + } + + return nil // no AllowUsers line, nothing to do +} + +// ensureSSHDInstalled installs openssh-server if missing, or upgrades it if +// a newer version is available. +func ensureSSHDInstalled() error { + cmd := exec.Command("sudo", "apt-get", "install", "-y", "openssh-server") // #nosec G204 + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("apt-get install openssh-server: %w", err) + } + return nil +} + +// ReloadBrevSSHD sends a reload signal to the brev-sshd service so it picks +// up config changes without dropping existing connections. +func ReloadBrevSSHD() error { + cmd := exec.Command("bash", "-c", "sudo systemctl reload brev-sshd.service") // #nosec G204 + if err := cmd.Run(); err != nil { + return fmt.Errorf("reloading brev-sshd: %w", err) + } + return nil +} diff --git a/pkg/cmd/register/sshd_test.go b/pkg/cmd/register/sshd_test.go new file mode 100644 index 00000000..6257d89c --- /dev/null +++ b/pkg/cmd/register/sshd_test.go @@ -0,0 +1,158 @@ +package register + +import ( + "os" + "path/filepath" + "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{} +} + +// helper to write a temp sshd_config and return its path. +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "sshd_config") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("writing temp config: %v", err) + } + return p +} + +func readConfig(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) // #nosec G304 + if err != nil { + t.Fatalf("reading config: %v", err) + } + return string(data) +} + +func Test_addAllowedUser_CreatesDirective(t *testing.T) { + p := writeTempConfig(t, "Port 2222\n") + if err := addAllowedUser(p, "alice"); err != nil { + t.Fatalf("addAllowedUser: %v", err) + } + got := readConfig(t, p) + if !strings.Contains(got, "AllowUsers alice") { + t.Errorf("expected AllowUsers alice, got:\n%s", got) + } +} + +func Test_addAllowedUser_AppendsToExisting(t *testing.T) { + p := writeTempConfig(t, "Port 2222\nAllowUsers alice\n") + if err := addAllowedUser(p, "bob"); err != nil { + t.Fatalf("addAllowedUser: %v", err) + } + got := readConfig(t, p) + if !strings.Contains(got, "AllowUsers alice bob") { + t.Errorf("expected AllowUsers alice bob, got:\n%s", got) + } +} + +func Test_addAllowedUser_Idempotent(t *testing.T) { + p := writeTempConfig(t, "Port 2222\nAllowUsers alice\n") + if err := addAllowedUser(p, "alice"); err != nil { + t.Fatalf("addAllowedUser: %v", err) + } + got := readConfig(t, p) + if strings.Count(got, "alice") != 1 { + t.Errorf("expected exactly one 'alice', got:\n%s", got) + } +} + +func Test_removeAllowedUser_RemovesUser(t *testing.T) { + p := writeTempConfig(t, "Port 2222\nAllowUsers alice bob\n") + if err := removeAllowedUser(p, "alice"); err != nil { + t.Fatalf("removeAllowedUser: %v", err) + } + got := readConfig(t, p) + if !strings.Contains(got, "AllowUsers bob") { + t.Errorf("expected AllowUsers bob, got:\n%s", got) + } + if strings.Contains(got, "alice") { + t.Errorf("alice should have been removed, got:\n%s", got) + } +} + +func Test_removeAllowedUser_RemovesDirectiveWhenEmpty(t *testing.T) { + p := writeTempConfig(t, "Port 2222\nAllowUsers alice\n") + if err := removeAllowedUser(p, "alice"); err != nil { + t.Fatalf("removeAllowedUser: %v", err) + } + got := readConfig(t, p) + if strings.Contains(got, "AllowUsers") { + t.Errorf("AllowUsers directive should be removed, got:\n%s", got) + } +} + +func Test_removeAllowedUser_NoOpWhenMissing(t *testing.T) { + p := writeTempConfig(t, "Port 2222\n") + if err := removeAllowedUser(p, "alice"); err != nil { + t.Fatalf("removeAllowedUser: %v", err) + } +} + +func Test_removeAllowedUser_NoOpWhenFileAbsent(t *testing.T) { + if err := removeAllowedUser("/nonexistent/path/sshd_config", "alice"); err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } +} diff --git a/pkg/cmd/register/sshkeys.go b/pkg/cmd/register/sshkeys.go index db36f01b..a29d64ce 100644 --- a/pkg/cmd/register/sshkeys.go +++ b/pkg/cmd/register/sshkeys.go @@ -41,6 +41,18 @@ func GrantSSHAccessToNode( } } + // Add the Linux user to the brev-sshd AllowUsers directive so the + // managed sshd on port 2222 accepts connections for this user. + allowUsersAdded := false + if err := AddAllowedUser(osUser.Username); err != nil { + t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to add user to AllowUsers: %v", err))) + } else { + allowUsersAdded = true + if err := ReloadBrevSSHD(); err != nil { + t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to reload brev-sshd: %v", err))) + } + } + client := nodeClients.NewNodeClient(tokenProvider, config.GlobalConfig.GetBrevPublicAPIURL()) _, err := client.GrantNodeSSHAccess(ctx, connect.NewRequest(&nodev1.GrantNodeSSHAccessRequest{ ExternalNodeId: reg.ExternalNodeID, @@ -53,6 +65,13 @@ func GrantSSHAccessToNode( t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove SSH key after failed grant: %v", rerr))) } } + if allowUsersAdded { + if rerr := RemoveAllowedUser(osUser.Username); rerr != nil { + t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to roll back AllowUsers after failed grant: %v", rerr))) + } else { + _ = ReloadBrevSSHD() + } + } return fmt.Errorf("failed to grant SSH access: %w", err) }