From 2fc5e4e684f93f954456d99cf14df7a3468e5800 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 7 Aug 2026 13:31:14 -0700 Subject: [PATCH 01/23] docs: design BYON network join command --- .../2026-08-07-byon-network-join-design.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-byon-network-join-design.md diff --git a/docs/superpowers/specs/2026-08-07-byon-network-join-design.md b/docs/superpowers/specs/2026-08-07-byon-network-join-design.md new file mode 100644 index 00000000..ef326432 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-byon-network-join-design.md @@ -0,0 +1,173 @@ +# BYON Network Join Command Design + +## Summary + +Replace `brev register` as the canonical BYON onboarding command with +`brev join`. The command joins the current Linux machine to the selected +organization's Brev network by installing and connecting NetBird, creating the +external node, and persisting its local identity. + +SSH access is not part of joining the network. Users explicitly run +`brev enable-ssh` to enable SSH for themselves and `brev grant-ssh` to share +SSH access with another organization member. + +`brev register` remains a deprecated Cobra alias for `brev join`. This change +does not set a removal release for the alias. + +## Goals + +- Make BYON network membership the sole purpose of the onboarding command. +- Use `join` as the user-facing verb for durable membership in an + organization's Brev network. +- Preserve existing `brev register` automation that does not request SSH. +- Make the removal of implicit SSH enablement explicit and actionable. +- Keep `enable-ssh` and `grant-ssh` as separate, intentional access-control + operations. + +## Non-goals + +- Renaming `brev deregister` or introducing `brev leave`. +- Changing `brev enable-ssh`, `brev grant-ssh`, `brev revoke-ssh`, or SSH + authorization semantics. +- Moving or renaming the `pkg/cmd/register` package, persisted + `DeviceRegistration` model, registration file, backend RPCs, or proto fields. +- Changing how an organization or its current default Brev network is selected. +- Refactoring shared external-node helpers unrelated to the command boundary. + +## Command Surface + +The existing command constructor becomes `NewCmdJoin`. Its Cobra metadata is: + +```go +Use: "join", +Aliases: []string{"register"}, +``` + +The public flags are: + +- `--name`, `-n`: device name; required with `--org` in non-interactive mode. +- `--org`, `-o`: organization name; required with `--name` in + non-interactive mode. +- `--approve`: skip the confirmation prompt. + +The `--ssh-port`, `-p` flag is removed from the public command contract. A +hidden compatibility flag recognizes both forms and returns an error before +any sudo, authentication, installation, RPC, or local persistence side effect: + +```text +--ssh-port is no longer supported by brev join or brev register; run brev join, +then run brev enable-ssh on the joined machine +``` + +This provides a migration message for existing scripts without retaining SSH +behavior in the join flow. + +When Cobra reports that the command was invoked as `register`, the command +writes a warning to stderr and continues through the same join handler: + +```text +Warning: "brev register" is deprecated; use "brev join" instead. +This command no longer enables SSH; run "brev enable-ssh" separately. +``` + +Warnings go to stderr so normal stdout remains usable by scripts. The alias is +accepted in both interactive and non-interactive modes. + +## Join Flow + +`runJoin` owns orchestration and retains the existing network-membership +sequence: + +1. Verify Linux compatibility and obtain sudo authorization. +2. Resolve the authenticated Brev user. +3. If a local device registration exists, reconcile and report NetBird + connectivity without changing SSH state. +4. Resolve the device name and target organization from prompts or flags. +5. Confirm that the operation will install the Brev tunnel, collect a hardware + profile, add the node to Brev, persist its identity, and connect it to the + organization's Brev network. +6. Install NetBird, collect the hardware profile, call `AddNode`, save the + `DeviceRegistration`, and execute the backend-provided NetBird setup command. +7. Report network membership success and state that SSH access was not enabled. + +The success output ends with an explicit optional next step: + +```text +SSH access was not enabled. To enable it for your user, run: brev enable-ssh +``` + +The join flow never prompts for an SSH port, looks up the current Linux user for +an SSH grant, opens a port, installs an authorized key, or calls an SSH-access +RPC. + +## Code Boundaries + +- `pkg/cmd/cmd.go` registers `register.NewCmdJoin(...)` once. Cobra resolves + both `join` and its `register` alias to that command. +- The existing `pkg/cmd/register` directory remains because it contains the + durable device-registration model and shared external-node helpers used by + other commands. +- Command-specific names in `register.go` and its tests change from register to + join where they describe the user-facing flow, including `NewCmdJoin`, + `joinOpts`, `runJoin`, and `runJoinSteps`. +- The SSH orchestration currently appended to `runRegister` is removed. Shared + SSH helpers still used by `enable-ssh`, `grant-ssh`, `revoke-ssh`, or + `deregister` remain available. +- User-facing guidance that currently says to run `brev register` first changes + to `brev join`. +- Internal/backend messages may continue to use registration terminology when + they describe `AddNode` or persisted registration state rather than the CLI + action. + +## Compatibility and Error Handling + +- `brev register`, `brev register --name ... --org ...`, and + `brev register --approve` continue to invoke the join flow. +- The alias warning is emitted on every non-help execution of `register`; it is + not emitted for canonical `join` invocations. +- `register --ssh-port`, `register -p`, `join --ssh-port`, and `join -p` fail + before side effects with the targeted migration error. +- The legacy SSH flag is never ignored and never translated into an implicit + `enable-ssh` operation. +- Existing idempotent behavior for an already joined machine remains: check the + backend node status, ensure the local NetBird service is connected, and tell + the user to run `brev deregister` before joining differently. +- Failures retain operation context and preserve the existing boundary between + fatal membership failures and warning-only tunnel status checks. + +## Testing + +Tests will cover: + +- Cobra exposes `join` as the canonical command and resolves `register` as its + alias. +- Canonical and alias invocations reach the same network-membership handler. +- `register` writes the deprecation/SSH-separation warning to stderr, while + `join` does not. +- Interactive join prompts only for device name, organization, and membership + confirmation; it never asks about SSH. +- Non-interactive join still requires both `--name` and `--org`. +- All four legacy SSH flag forms fail with the migration error before any + membership or SSH dependency is called. +- Successful join installs/connects NetBird, creates and persists the node, and + makes no SSH port or grant call. +- Existing already-joined connectivity reconciliation remains intact. +- Existing focused tests for `enable-ssh`, `grant-ssh`, `revoke-ssh`, and + `deregister` continue to pass unchanged unless user-facing `join` guidance is + updated. + +Verification will run formatting and the focused command-package tests. The +repository-wide suite will also be attempted, but its current macOS baseline +has unrelated Linux e2e, JetBrains Gateway, and WSL-specific failures; those +failures will be reported separately rather than attributed to this change. + +## Documentation and Release Notes + +- Cobra help presents `join` as the canonical command and identifies + `register` as an alias. +- Examples use `brev join --name --org ` and show + `brev enable-ssh` as a separate optional command. +- Release notes call out the behavior change: `register` no longer offers or + enables SSH, and `--ssh-port` now returns migration guidance. +- Removing the `register` alias requires a future explicit compatibility + decision; this design does not schedule its removal. From 2954f39e8d0c6ec1d4e271f640bca327648aa2d3 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 7 Aug 2026 17:31:38 -0700 Subject: [PATCH 02/23] docs: design BYON network and SSH separation --- .../2026-08-07-byon-network-join-design.md | 173 ------- ...8-07-byon-network-ssh-separation-design.md | 447 ++++++++++++++++++ 2 files changed, 447 insertions(+), 173 deletions(-) delete mode 100644 docs/superpowers/specs/2026-08-07-byon-network-join-design.md create mode 100644 docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md diff --git a/docs/superpowers/specs/2026-08-07-byon-network-join-design.md b/docs/superpowers/specs/2026-08-07-byon-network-join-design.md deleted file mode 100644 index ef326432..00000000 --- a/docs/superpowers/specs/2026-08-07-byon-network-join-design.md +++ /dev/null @@ -1,173 +0,0 @@ -# BYON Network Join Command Design - -## Summary - -Replace `brev register` as the canonical BYON onboarding command with -`brev join`. The command joins the current Linux machine to the selected -organization's Brev network by installing and connecting NetBird, creating the -external node, and persisting its local identity. - -SSH access is not part of joining the network. Users explicitly run -`brev enable-ssh` to enable SSH for themselves and `brev grant-ssh` to share -SSH access with another organization member. - -`brev register` remains a deprecated Cobra alias for `brev join`. This change -does not set a removal release for the alias. - -## Goals - -- Make BYON network membership the sole purpose of the onboarding command. -- Use `join` as the user-facing verb for durable membership in an - organization's Brev network. -- Preserve existing `brev register` automation that does not request SSH. -- Make the removal of implicit SSH enablement explicit and actionable. -- Keep `enable-ssh` and `grant-ssh` as separate, intentional access-control - operations. - -## Non-goals - -- Renaming `brev deregister` or introducing `brev leave`. -- Changing `brev enable-ssh`, `brev grant-ssh`, `brev revoke-ssh`, or SSH - authorization semantics. -- Moving or renaming the `pkg/cmd/register` package, persisted - `DeviceRegistration` model, registration file, backend RPCs, or proto fields. -- Changing how an organization or its current default Brev network is selected. -- Refactoring shared external-node helpers unrelated to the command boundary. - -## Command Surface - -The existing command constructor becomes `NewCmdJoin`. Its Cobra metadata is: - -```go -Use: "join", -Aliases: []string{"register"}, -``` - -The public flags are: - -- `--name`, `-n`: device name; required with `--org` in non-interactive mode. -- `--org`, `-o`: organization name; required with `--name` in - non-interactive mode. -- `--approve`: skip the confirmation prompt. - -The `--ssh-port`, `-p` flag is removed from the public command contract. A -hidden compatibility flag recognizes both forms and returns an error before -any sudo, authentication, installation, RPC, or local persistence side effect: - -```text ---ssh-port is no longer supported by brev join or brev register; run brev join, -then run brev enable-ssh on the joined machine -``` - -This provides a migration message for existing scripts without retaining SSH -behavior in the join flow. - -When Cobra reports that the command was invoked as `register`, the command -writes a warning to stderr and continues through the same join handler: - -```text -Warning: "brev register" is deprecated; use "brev join" instead. -This command no longer enables SSH; run "brev enable-ssh" separately. -``` - -Warnings go to stderr so normal stdout remains usable by scripts. The alias is -accepted in both interactive and non-interactive modes. - -## Join Flow - -`runJoin` owns orchestration and retains the existing network-membership -sequence: - -1. Verify Linux compatibility and obtain sudo authorization. -2. Resolve the authenticated Brev user. -3. If a local device registration exists, reconcile and report NetBird - connectivity without changing SSH state. -4. Resolve the device name and target organization from prompts or flags. -5. Confirm that the operation will install the Brev tunnel, collect a hardware - profile, add the node to Brev, persist its identity, and connect it to the - organization's Brev network. -6. Install NetBird, collect the hardware profile, call `AddNode`, save the - `DeviceRegistration`, and execute the backend-provided NetBird setup command. -7. Report network membership success and state that SSH access was not enabled. - -The success output ends with an explicit optional next step: - -```text -SSH access was not enabled. To enable it for your user, run: brev enable-ssh -``` - -The join flow never prompts for an SSH port, looks up the current Linux user for -an SSH grant, opens a port, installs an authorized key, or calls an SSH-access -RPC. - -## Code Boundaries - -- `pkg/cmd/cmd.go` registers `register.NewCmdJoin(...)` once. Cobra resolves - both `join` and its `register` alias to that command. -- The existing `pkg/cmd/register` directory remains because it contains the - durable device-registration model and shared external-node helpers used by - other commands. -- Command-specific names in `register.go` and its tests change from register to - join where they describe the user-facing flow, including `NewCmdJoin`, - `joinOpts`, `runJoin`, and `runJoinSteps`. -- The SSH orchestration currently appended to `runRegister` is removed. Shared - SSH helpers still used by `enable-ssh`, `grant-ssh`, `revoke-ssh`, or - `deregister` remain available. -- User-facing guidance that currently says to run `brev register` first changes - to `brev join`. -- Internal/backend messages may continue to use registration terminology when - they describe `AddNode` or persisted registration state rather than the CLI - action. - -## Compatibility and Error Handling - -- `brev register`, `brev register --name ... --org ...`, and - `brev register --approve` continue to invoke the join flow. -- The alias warning is emitted on every non-help execution of `register`; it is - not emitted for canonical `join` invocations. -- `register --ssh-port`, `register -p`, `join --ssh-port`, and `join -p` fail - before side effects with the targeted migration error. -- The legacy SSH flag is never ignored and never translated into an implicit - `enable-ssh` operation. -- Existing idempotent behavior for an already joined machine remains: check the - backend node status, ensure the local NetBird service is connected, and tell - the user to run `brev deregister` before joining differently. -- Failures retain operation context and preserve the existing boundary between - fatal membership failures and warning-only tunnel status checks. - -## Testing - -Tests will cover: - -- Cobra exposes `join` as the canonical command and resolves `register` as its - alias. -- Canonical and alias invocations reach the same network-membership handler. -- `register` writes the deprecation/SSH-separation warning to stderr, while - `join` does not. -- Interactive join prompts only for device name, organization, and membership - confirmation; it never asks about SSH. -- Non-interactive join still requires both `--name` and `--org`. -- All four legacy SSH flag forms fail with the migration error before any - membership or SSH dependency is called. -- Successful join installs/connects NetBird, creates and persists the node, and - makes no SSH port or grant call. -- Existing already-joined connectivity reconciliation remains intact. -- Existing focused tests for `enable-ssh`, `grant-ssh`, `revoke-ssh`, and - `deregister` continue to pass unchanged unless user-facing `join` guidance is - updated. - -Verification will run formatting and the focused command-package tests. The -repository-wide suite will also be attempted, but its current macOS baseline -has unrelated Linux e2e, JetBrains Gateway, and WSL-specific failures; those -failures will be reported separately rather than attributed to this change. - -## Documentation and Release Notes - -- Cobra help presents `join` as the canonical command and identifies - `register` as an alias. -- Examples use `brev join --name --org ` and show - `brev enable-ssh` as a separate optional command. -- Release notes call out the behavior change: `register` no longer offers or - enables SSH, and `--ssh-port` now returns migration guidance. -- Removing the `register` alias requires a future explicit compatibility - decision; this design does not schedule its removal. diff --git a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md new file mode 100644 index 00000000..f34cf9bf --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md @@ -0,0 +1,447 @@ +# BYON Network and SSH Command Separation Design + +## Summary + +Separate BYON network membership from Brev-managed SSH access through four +explicit command boundaries: + +| Capability | Canonical command | Compatibility alias | +| --- | --- | --- | +| Join the organization's Brev network | `brev join` | `brev register` | +| Leave the organization's Brev network | `brev leave` | `brev deregister` | +| Enable Brev-managed SSH for the current Brev/Linux user | `brev enable-ssh` | None | +| Disable all Brev-managed SSH access on the node | `brev disable-ssh` | None | + +`join` and `leave` own only durable Brev/NetBird membership. `enable-ssh` and +`disable-ssh` own Brev-managed SSH authentication. `grant-ssh` and `revoke-ssh` +remain the commands for individual collaborator grants. + +`register` and `deregister` remain deprecated Cobra aliases with no scheduled +removal release. Their handlers and behavior are the same as their canonical +commands, including the new separation from SSH. + +## Goals + +- Make joining an organization's Brev network the sole purpose of the onboarding + command. +- Use the durable membership pair `join` and `leave` rather than registration + terminology. +- Require an established and connected Brev tunnel before SSH can be enabled. +- Make node-wide SSH disablement an explicit operation independent of network + membership. +- Preserve individual collaborator management through `grant-ssh` and + `revoke-ssh`. +- Preserve compatible automation through deprecated `register` and + `deregister` aliases, with actionable migration output. +- Make partial teardown failures visible and safely retryable. + +## Non-goals + +- Stopping or reconfiguring the host's SSH daemon. +- Closing Brev port allocations from `disable-ssh`. Existing ports may have been + reused for purposes other than SSH, and the current model does not record + ownership. +- Adding port-ownership metadata, changing backend RPC or proto shapes, or + introducing a new backend bulk-revocation RPC. +- Changing the authorization semantics of `grant-ssh` or `revoke-ssh`. +- Renaming the internal `pkg/cmd/register` package, the persisted + `DeviceRegistration` model, registration file, or backend node terminology. +- Changing organization or default-network selection. +- Tracking whether Brev installed NetBird. `leave` preserves today's NetBird + uninstall behavior; protecting a pre-existing user-managed NetBird + installation is a separate follow-up. +- Forcibly terminating already-established SSH sessions. Key removal prevents + future authentication but does not kill active sessions. + +## Naming Rationale + +The names follow established networking conventions: + +- ZeroTier uses `join` and `leave` for durable network membership. +- Tailscale uses `down` for a reversible disconnect and separates SSH enablement + from tailnet membership. +- NetBird also treats `down` as a temporary idle state and manages peer + membership separately. + +For Brev, `leave` therefore means authoritative membership removal rather than a +temporary tunnel stop. `logout` is avoided because it conventionally describes +human or device authentication state. `peer` is avoided as the command verb +because it describes the resulting network object rather than the user action. + +References: + +- [ZeroTier CLI](https://docs.zerotier.com/cli/) +- [Tailscale CLI](https://tailscale.com/docs/reference/tailscale-cli) +- [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh) +- [NetBird CLI](https://docs.netbird.io/get-started/cli) +- [NetBird SSH](https://docs.netbird.io/manage/peers/ssh) + +## Command Model + +The intended user workflow is: + +```text +brev join +brev enable-ssh +brev grant-ssh +``` + +A complete retirement is intentionally two explicit operations: + +```text +brev disable-ssh +brev leave +``` + +Running `leave` without `disable-ssh` is allowed. Brev-routed SSH stops because +the node leaves the network, but Brev-added keys can remain in local +`authorized_keys` files and may still work through another network path. + +### Join and Register Alias + +The existing command constructor becomes `NewCmdJoin` with canonical Cobra +metadata: + +```go +Use: "join", +Aliases: []string{"register"}, +Args: cobra.NoArgs, +``` + +Public flags remain: + +- `--name`, `-n`: device name; required with `--org` in non-interactive mode. +- `--org`, `-o`: organization name; required with `--name` in non-interactive + mode. +- `--approve`: skip the confirmation prompt. + +The legacy `--ssh-port`, `-p` flag is removed from the public contract. A hidden +compatibility flag recognizes both forms and returns this error before sudo, +authentication, installation, RPC, or persistence side effects: + +```text +--ssh-port is no longer supported by brev join or brev register; run brev join, +then run brev enable-ssh on the joined machine +``` + +When `cmd.CalledAs()` reports `register`, the command writes this warning to +stderr and continues through the join handler: + +```text +Warning: "brev register" is deprecated; use "brev join" instead. +This command no longer enables SSH; run "brev enable-ssh" separately. +``` + +The warning is emitted for executions, not help rendering. Stdout remains +available for normal command output and scripts. + +### Join Flow + +`runJoin` retains only network-membership orchestration: + +1. Verify Linux compatibility and obtain sudo authorization. +2. Resolve the authenticated Brev user. +3. If local registration already exists, reconcile the backend node and local + NetBird connection without changing SSH state. +4. Resolve device name and organization from prompts or flags. +5. Confirm that the operation installs the Brev tunnel, collects a hardware + profile, creates the external node, persists identity, and joins the + organization's Brev network. +6. Install NetBird, collect hardware, call `AddNode`, save + `DeviceRegistration`, and run the backend-provided `netbird up` command. +7. Report network membership success and the optional next step: + +```text +SSH access was not enabled. To enable it for your user, run: brev enable-ssh +``` + +The flow never prompts for an SSH port, looks up a Linux account for a grant, +opens a port, installs an authorized key, or calls an SSH-access RPC. + +### Enable SSH + +`brev enable-ssh` remains the self-enablement command for the current Brev user +and current Linux account. It has a hard network-membership precondition: + +1. Verify Linux compatibility. +2. Load local `DeviceRegistration`. If none exists, fail with targeted guidance: + + ```text + This machine has not joined a Brev network; run "brev join" first. + ``` + +3. Authenticate and verify that the registered backend node still exists. +4. Ensure the existing NetBird service is running and connected. A temporarily + disconnected tunnel is started or reconnected automatically. +5. Wait for bounded, positive connectivity confirmation. A status error or + unconfirmed connection is a failure rather than assumed success. +6. Only then select or open a port, install the current user's tagged key, and + create the current user's SSH access record. + +`enable-ssh` may reconnect existing membership, but it never calls `AddNode`, +selects an organization, writes a new registration, or otherwise performs an +implicit join. A failed membership, node, or tunnel check occurs before any SSH +port, key, or access-record mutation. + +### Grant and Revoke SSH + +`brev grant-ssh` and `brev revoke-ssh` retain their existing roles: + +- `grant-ssh` creates one exact collaborator access tuple for a node, port, and + Linux account. +- `revoke-ssh` removes one exact access tuple. + +They do not become aliases or modes of `enable-ssh` or `disable-ssh`. + +### Disable SSH + +Add a canonical top-level command: + +```go +Use: "disable-ssh", +Args: cobra.NoArgs, +``` + +It accepts `--approve` to skip confirmation. It operates only on the locally +registered node and means "disable every Brev-managed SSH credential on this +node." It does not mean "stop sshd." + +The flow is: + +1. Verify Linux compatibility. +2. Load local registration; if absent, direct the user to `brev join`. +3. Authenticate, fetch the registered backend node, and snapshot every active + `SSHAccess` tuple. +4. Show a node-wide confirmation with the grant and Linux-account counts. State + that active sessions are not forcibly terminated. +5. Obtain sudo authorization for node-wide local key cleanup. +6. When active grants exist, ensure the existing Brev tunnel is connected so + remote key revocation can complete. Reconnect existing membership + automatically, but never join. If no grants exist, skip this network + requirement and continue to orphaned local-key cleanup. +7. Call `RevokeNodeSSHAccess` sequentially for every tuple while the node and + its referenced ports still exist. Sequential execution avoids concurrent + rewrites of one Linux account's `authorized_keys`. Attempt all entries and + aggregate contextual failures. +8. If any backend revocation fails, return nonzero and do not perform the broad + local sweep. Successful revocations remain successful; a retry fetches and + processes the remaining records. +9. Once no backend access records remain, enumerate accounts reported by the + local OS account database and inspect only each account's + `.ssh/authorized_keys`. Remove only lines carrying Brev's current + `#brev-portID:...` marker or legacy `# brev-cli` marker. +10. Report success only after both authoritative revocation and local tagged-key + cleanup succeed. + +No-access and no-key states are successful, making the command safely +repeatable. If the local sweep fails after backend revocation, membership and +registration remain intact so a retry can complete the sweep. + +`disable-ssh` does not remove the backend node, stop or uninstall NetBird, delete +registration, stop sshd, or close ports. Ports remain because the current API +cannot distinguish ports created for SSH from pre-existing ports selected by +the SSH flow. + +### Leave and Deregister Alias + +The existing deregistration constructor becomes `NewCmdLeave`: + +```go +Use: "leave", +Aliases: []string{"deregister"}, +Args: cobra.NoArgs, +``` + +It retains `--approve`. When invoked as `deregister`, it writes this warning to +stderr: + +```text +Warning: "brev deregister" is deprecated; use "brev leave" instead. +This command no longer removes SSH keys; run "brev disable-ssh" before leaving +if you want to remove Brev-managed SSH access. +``` + +The leave flow owns only membership teardown: + +1. Verify Linux compatibility, load local registration, and authenticate. +2. Fetch the registered node when it still exists and inspect its SSH access + records. Backend not-found is the idempotent retry case; any other lookup + error stops before mutation. +3. Always warn that removing the Brev tunnel may interrupt a command running + through Brev SSH. Recommend running locally or through out-of-band access. +4. If SSH access records remain, explain that Brev-routed SSH will stop but host + keys will not be removed. Tell the user to cancel and run + `brev disable-ssh` first if key removal is desired. +5. Confirm unless `--approve` was supplied. Warnings still print with + `--approve`. +6. Obtain sudo authorization before network removal so local teardown will not + require a new password prompt after connectivity is lost. +7. Call `RemoveNode`. Backend removal authoritatively removes network membership, + deallocates node ports, and deletes SSH access metadata, but it does not + remove physical host keys. +8. Uninstall NetBird using the existing Brev tunnel teardown behavior. +9. Delete local registration last. +10. Report completion only after membership and local teardown succeed. + +`leave` never calls `RevokeNodeSSHAccess` and never edits +`authorized_keys`. This preserves the command boundary even when grants exist. + +An already-removed backend node is treated as success during a retry. If backend +removal succeeds but NetBird uninstall or registration deletion fails, the +registration is retained when possible so `leave` can resume. Local failures +return nonzero rather than producing a false successful completion. + +## Code Boundaries + +- `pkg/cmd/cmd.go` registers `register.NewCmdJoin(...)`, + `deregister.NewCmdLeave(...)`, and the new + `disablessh.NewCmdDisableSSH(...)` exactly once. Cobra resolves aliases. +- `pkg/cmd/register` remains the home of the durable registration model and + shared external-node helpers. User-facing orchestration names change to + `joinOpts`, `runJoin`, and `runJoinSteps`. +- The SSH tail currently appended to registration is removed. Existing SSH + helpers remain available to SSH commands. +- `pkg/cmd/deregister` retains its internal package name but owns only leave + orchestration. Its direct authorized-key removal dependency is removed. +- `pkg/cmd/disablessh` is a focused new package with injected dependencies for + registration, node lookup, tunnel connectivity, sudo, confirmation, grant + revocation, and local account key cleanup. +- A narrow local key-cleanup abstraction enumerates account homes and removes + only Brev-tagged lines from each account's `.ssh/authorized_keys`, without + recursing through home directories. Rewrites preserve unrelated lines, + ownership, and file mode. Tests use a fake rather than touching real home + directories. +- Tunnel management gains a strict connected operation suitable for SSH + preconditions. It can start the service and run `netbird up` for existing + membership, but returns an error unless connectivity is positively confirmed. +- `enable-ssh` always uses that strict tunnel operation. `disable-ssh` uses the + same operation when active grants require remote revocation, while a + no-grant run can proceed directly to orphaned local-key cleanup. +- User-facing guidance throughout the CLI changes from `brev register` to + `brev join`. Internal and backend registration terminology remains where it + describes persisted state or `AddNode`. + +## Compatibility + +- `brev register`, including its `--name`, `--org`, and `--approve` flags, + continues through the join flow. +- `brev deregister --approve` continues through the leave flow. +- Both aliases warn on stderr with their canonical replacement. +- The aliases do not retain legacy SSH side effects. +- All forms of the old SSH flag, `register --ssh-port`, `register -p`, + `join --ssh-port`, and `join -p`, fail before side effects with migration + guidance. +- `deregister` no longer removes the invoking user's Brev-tagged keys. Its + warning tells callers to run `disable-ssh` first when they want credential + cleanup. +- Removing either alias requires a future explicit compatibility decision. + +## Error Handling and Recovery + +- Membership validation and strict tunnel connectivity precede every + `enable-ssh` mutation. +- `disable-ssh` attempts every backend revocation, reports each failed tuple with + user, Linux account, and port context, and returns a combined error. +- The local node-wide key sweep runs only after authoritative access records are + gone, preventing local cleanup from stranding backend revocation. +- `leave` deletes registration last and treats backend not-found as an + idempotent retry condition. +- Neither teardown command prints success after an incomplete operation. +- Errors wrap the failed operation while preserving the underlying error for + callers and tests. +- Deprecation and safety warnings use stderr; ordinary status and success output + use the terminal's normal output path. + +## Testing + +### Command Surface + +Tests verify: + +- Cobra exposes `join` and `leave` as canonical commands. +- `register` and `deregister` resolve as aliases to the same handlers. +- Canonical invocations do not warn; aliases warn on stderr. +- All four affected commands reject positional arguments. +- Help and examples use canonical names. + +### Join + +Tests verify: + +- Interactive join prompts only for device name, organization, and membership + confirmation. +- Non-interactive join still requires both `--name` and `--org`. +- Legacy SSH flags fail before membership or SSH dependencies are called. +- Successful join installs and connects NetBird, creates and persists the node, + and makes no port or SSH-access call. +- Existing joined-node reconciliation remains intact. + +### Enable SSH + +Tests verify: + +- Missing registration returns targeted `brev join` guidance. +- A missing backend node fails without joining or mutating SSH. +- A connected tunnel proceeds normally. +- A disconnected existing tunnel reconnects and then proceeds. +- Failed or unconfirmed reconnection causes no port, key, or SSH-access side + effect. +- The command never calls `AddNode`. + +### Disable SSH + +Tests verify: + +- Confirmation describes node-wide scope and can be bypassed with `--approve`. +- Every active access tuple is revoked exactly once. +- Active grants require a connected tunnel; a disconnected existing tunnel is + reconnected before revocation. +- With no active grants, tunnel failure does not block the orphaned local-key + sweep. +- All tuples are attempted even when one fails, and errors are aggregated. +- The local tagged-key sweep does not run after any revocation failure. +- After successful revocation, current and legacy Brev markers are removed + across account homes while unrelated keys, ownership, and file modes remain + intact. +- No-access and no-key runs succeed. +- A local sweep failure returns nonzero and is retryable. +- No node removal, NetBird teardown, registration deletion, sshd operation, or + port close occurs. + +### Leave + +Tests verify: + +- Remaining grants produce the SSH-key warning but do not block leave. +- `--approve` skips confirmation but not warnings. +- No SSH revoke or authorized-key dependency is called. +- Ordering is backend removal, NetBird uninstall, then registration deletion. +- Backend removal failure stops local teardown. +- Backend not-found is accepted during retry. +- Other backend lookup failures stop before confirmation or mutation. +- NetBird or registration cleanup failure returns nonzero and preserves + recoverable state where possible. +- Success is printed only after complete membership teardown. + +### Verification Commands + +Implementation verification will include `gofmt` on touched Go files, focused +command-package tests, and `golangci-lint` when configured and practical. The +repository-wide suite will also be attempted. + +The current macOS baseline has unrelated failures in Linux e2e setup, JetBrains +Gateway detection, and WSL-specific store tests. Those failures will be reported +separately and will not be attributed to this change. + +## Documentation and Release Notes + +- CLI help and examples use `join` and `leave` as the primary verbs. +- Onboarding documents show `enable-ssh` as an explicit post-join choice. +- Offboarding documents show `disable-ssh` followed by `leave` for complete + credential and membership removal. +- Documentation states that `leave` alone makes the node unreachable over the + Brev network but does not remove host keys. +- Release notes call out both deprecated aliases and the SSH behavior change. +- The existing risk that `leave` may uninstall a pre-existing user-managed + NetBird installation is documented as a follow-up, not silently changed in + this implementation. From c666f75866c98b912f73e12d30fb1165bc6860e0 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 7 Aug 2026 18:49:23 -0700 Subject: [PATCH 03/23] docs: plan BYON network and SSH separation --- .../2026-08-07-byon-network-ssh-separation.md | 1248 +++++++++++++++++ 1 file changed, 1248 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md diff --git a/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md b/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md new file mode 100644 index 00000000..01b897ab --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md @@ -0,0 +1,1248 @@ +# BYON Network and SSH Separation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `join`/`leave` own BYON NetBird membership, make `enable-ssh`/`disable-ssh` own Brev-managed SSH credentials, and retain `register`/`deregister` only as deprecated aliases. + +**Architecture:** Keep persisted registration and shared external-node helpers in `pkg/cmd/register`, add one strict reconnecting NetBird primitive, and put node-wide SSH revocation plus privileged local-key cleanup in a new `pkg/cmd/disablessh` package. The SSH commands validate existing membership before mutation; teardown commands preserve retry state and never cross the membership/credential boundary. + +**Tech Stack:** Go 1.25, Cobra, ConnectRPC/protobuf, `golang.org/x/sys/unix` for Linux descriptor-safe file operations, standard-library JSON/process APIs, and the repository's existing terminal, sudo, store, and error packages. + +## Global Constraints + +- Implement only in `/Users/pratpatel/code/brev-cli-byon-network-join` on branch `codex/byon-network-join`. +- Treat `docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md` as the approved behavioral contract. +- Keep the internal `register` and `deregister` package names, `DeviceRegistration`, the registration file, backend RPC/proto shapes, organization selection, and the default Brev network unchanged. +- `join` must not prompt for SSH, resolve a Linux user, open a port, write `authorized_keys`, or grant SSH access. +- `enable-ssh` may reconnect an existing tunnel, but must never call `AddNode`, choose an organization, or create local registration. +- `disable-ssh` must not remove the node, close ports, stop sshd, uninstall NetBird, or delete registration. It removes all backend SSH-access tuples first and only then sweeps every local account for Brev-tagged keys. +- `leave` must not revoke SSH tuples or edit keys. It removes the node, uninstalls NetBird using the existing semantics, and deletes registration last. +- Keep `grant-ssh` and `revoke-ssh` behavior unchanged. +- All alias and safety warnings go to Cobra stderr; ordinary progress and success output continue through `terminal.Terminal`. +- Do not report success after partial teardown. Wrap errors with operation and tuple/account context and preserve their causes. +- Use TDD for every task: add the focused failing test, run it to observe the expected failure, implement the smallest behavior, rerun, then commit. +- Run `gofmt` on every touched Go file. Do not attribute the known macOS baseline failures in Linux e2e setup, JetBrains Gateway detection, or WSL store tests to this work. + +## File Map + +### Modify + +- `pkg/cmd/register/providers.go`: strict NetBird connection contract and injectable command runner. +- `pkg/cmd/register/register.go`: canonical `join`, `register` alias, hidden legacy flag, and membership-only orchestration. +- `pkg/cmd/register/register_test.go`: join surface, compatibility, and no-SSH regression coverage. +- `pkg/cmd/register/device_registration_store.go`: `brev join` recovery guidance. +- `pkg/cmd/register/device_registration_store_test.go`: guidance assertion. +- `pkg/cmd/register/sshkeys.go`: export the exact Brev-marker predicate for the privileged cleanup package. +- `pkg/cmd/register/sshkeys_test.go`: marker predicate coverage. +- `pkg/cmd/enablessh/enablessh.go`: hard joined-node and strict-tunnel preconditions. +- `pkg/cmd/enablessh/enablessh_test.go`: orchestration ordering and no-mutation tests. +- `pkg/cmd/deregister/deregister.go`: canonical `leave`, alias warning, warnings, and retry-safe membership-only teardown. +- `pkg/cmd/deregister/deregister_test.go`: alias, warning, ordering, retry, and failure tests. +- `pkg/cmd/cmd.go`: canonical root wiring and `disable-ssh` registration. +- `pkg/cmd/cmd_test.go`: root command/alias surface. +- `main.go`: dispatch the fixed privileged cleanup helper before normal CLI initialization. +- `README.md`, `CHANGELOG.md`, `.agents/skills/brev-cli/SKILL.md`, and `.agents/skills/brev-cli/reference/commands.md`: user guidance and release notes. + +### Create + +- `pkg/cmd/register/providers_test.go`: strict NetBird connection tests. +- `pkg/cmd/register/node.go` and `pkg/cmd/register/node_test.go`: shared registered-node lookup. +- `pkg/cmd/disablessh/disablessh.go` and `pkg/cmd/disablessh/disablessh_test.go`: public node-wide disable command. +- `pkg/cmd/disablessh/localkeys.go` and `pkg/cmd/disablessh/localkeys_test.go`: OS-neutral account parsing, byte filtering, helper protocol, and sudo runner. +- `pkg/cmd/disablessh/localkeys_linux.go` and `pkg/cmd/disablessh/localkeys_linux_test.go`: Linux NSS enumeration and secure `authorized_keys` rewrite. +- `pkg/cmd/disablessh/localkeys_unsupported.go`: unsupported-platform implementation for non-Linux builds. +- `pkg/cmd/disablessh/testdata/passwd.txt`, `authorized_keys.before`, and `authorized_keys.after`: deterministic cleanup fixtures. +- `docs/BYON.md`: explicit onboarding and retirement workflow. + +--- + +## Task 1: Add a Strict, Reconnecting NetBird Connection Primitive + +**Files:** + +- Modify: `pkg/cmd/register/providers.go` +- Modify: `pkg/cmd/register/register.go` +- Modify: `pkg/cmd/register/register_test.go` +- Modify: `pkg/cmd/deregister/deregister_test.go` +- Create: `pkg/cmd/register/providers_test.go` + +- [ ] **Step 1: Write the command-runner and connection tests** + +Add a scripted runner to `providers_test.go` that records exact commands and supplies queued output/errors: + +```go +type netBirdCall struct { + name string + args []string +} + +type netBirdResult struct { + output []byte + err error +} + +type fakeNetBirdCommandRunner struct { + results []netBirdResult + fallback netBirdResult + calls []netBirdCall +} + +func (f *fakeNetBirdCommandRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, netBirdCall{name: name, args: append([]string(nil), args...)}) + if len(f.results) == 0 { + return append([]byte(nil), f.fallback.output...), f.fallback.err + } + result := f.results[0] + f.results = f.results[1:] + return append([]byte(nil), result.output...), result.err +} + +func (f *fakeNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { + _, err := f.Output(ctx, name, args...) + return err +} +``` + +Cover these behaviors with `connectTimeout: 10 * time.Millisecond` and `pollInterval: time.Millisecond`. Set a sticky disconnected or error fallback in timeout tests so polling cannot exhaust a scripted slice: + +- `TestNetbirdEnsureConnected_AlreadyConnectedDoesNotReconnect`: active service and `Management: Connected`; no `sudo` call of any kind. +- `TestNetbirdEnsureConnected_StartsInactiveService`: inactive service calls `sudo systemctl start netbird` before status. +- `TestNetbirdEnsureConnected_ReconnectsAndWaitsForConfirmation`: disconnected status calls `sudo netbird up`, observes another disconnected status, then succeeds only on connected status. +- `TestNetbirdEnsureConnected_ReconnectFailure`: `netbird up` failure is returned with `failed to reconnect Brev tunnel` context. +- `TestNetbirdEnsureConnected_StatusNeverConfirmsConnection`: timeout returns `Brev tunnel connection was not confirmed`. +- `TestNetbirdEnsureConnected_StatusErrorsAreNotSuccess`: repeated status errors time out and preserve the last status failure. + +- [ ] **Step 2: Run the focused tests and observe the compile failure** + +Run: + +```bash +go test ./pkg/cmd/register -run '^TestNetbirdEnsureConnected_' -count=1 +``` + +Expected: FAIL because `Netbird` has no injected runner or `EnsureConnected` method. + +- [ ] **Step 3: Introduce the strict connector contract and runner** + +Replace the old permissive `EnsureRunning` contract with: + +```go +type NetBirdConnector interface { + EnsureConnected(context.Context) error +} + +type NetBirdManager interface { + NetBirdConnector + Install() error + Uninstall() error +} +``` + +In `providers.go`, make the zero value production-safe: + +```go +const ( + defaultNetBirdConnectTimeout = 30 * time.Second + defaultNetBirdPollInterval = 500 * time.Millisecond +) + +type netBirdCommandRunner interface { + Output(context.Context, string, ...string) ([]byte, error) + Run(context.Context, string, ...string) error +} + +type execNetBirdCommandRunner struct{} + +func (execNetBirdCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +func (execNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +type Netbird struct { + runner netBirdCommandRunner + connectTimeout time.Duration + pollInterval time.Duration +} +``` + +Add private default accessors so existing `Netbird{}` construction still works. Implement `EnsureConnected(ctx)` in this order: + +1. Check `systemctl is-active netbird`; when inactive or errored, run `sudo systemctl start netbird`. +2. Run `netbird status`; return immediately only when `netbirdManagementConnected` is true. +3. Otherwise run `sudo netbird up`. +4. Poll `netbird status` until it positively reports `Management: Connected`, the caller cancels, or the bounded timeout expires. +5. Treat status-command errors as unconfirmed status, remember the latest error, and include it in the timeout error. + +Move `netbirdManagementConnected` from `register.go` beside this implementation. Start the bounded `context.WithTimeout` only after any interactive `sudo` start/up command returns, so the 30-second positive-confirmation window does not cut off a password prompt. Use a timer/ticker for polling and do not sleep unconditionally in tests. + +- [ ] **Step 4: Make existing-registration reconciliation use the strict primitive** + +Update `checkExistingRegistration` so backend `CONNECTED` status no longer returns before checking the local tunnel. Always call: + +```go +if err := deps.netbird.EnsureConnected(ctx); err != nil { + t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: %v", err))) +} else { + t.Vprint(t.Green(" Brev tunnel is connected.")) +} +``` + +Retain warning-only behavior for this already-joined reconciliation path. Add `TestCheckExistingRegistration_ReconcilesLocalTunnel` by calling the existing helper directly, and update every `mockNetBirdManager` in both `register_test.go` and `deregister_test.go` with `EnsureConnected(context.Context) error` so the repository compiles between tasks. Task 2 may rename the test alongside the user-facing join orchestration. + +- [ ] **Step 5: Run tests and format** + +Run: + +```bash +gofmt -w pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/deregister/deregister_test.go +go test ./pkg/cmd/register ./pkg/cmd/deregister -run 'Test(NetbirdEnsureConnected|CheckExistingRegistration)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/deregister/deregister_test.go +git commit -m "feat: require confirmed Brev tunnel connectivity" +``` + + +--- + +## Task 2: Make `join` Canonical and Remove SSH from Membership Setup + +**Files:** + +- Modify: `pkg/cmd/register/register.go` +- Modify: `pkg/cmd/register/register_test.go` +- Modify: `pkg/cmd/register/device_registration_store.go` +- Modify: `pkg/cmd/register/device_registration_store_test.go` +- Modify: `pkg/cmd/cmd.go` +- Modify: `pkg/cmd/cmd_test.go` + +- [ ] **Step 1: Add command-surface and compatibility tests** + +Add tests with a parent Cobra command so both canonical and alias lookup execute the same command: + +```go +func TestNewCmdJoin_CommandSurface(t *testing.T) { + cmd := NewCmdJoin(testTerminal(t), panicRegisterStore{}) + root := &cobra.Command{Use: "brev"} + root.AddCommand(cmd) + resolved, _, err := root.Find([]string{"register"}) + require.NoError(t, err) + require.Equal(t, "join", cmd.Name()) + require.Equal(t, []string{"register"}, cmd.Aliases) + require.Same(t, cmd, resolved) + require.Error(t, cmd.Args(cmd, []string{"unexpected"})) + require.True(t, cmd.Flags().Lookup("ssh-port").Hidden) +} +``` + +Also add: + +- `TestNewCmdJoin_RegisterAliasWarnsOnExecution`: `register` writes the approved two-line warning to `cmd.SetErr(&stderr)` and then invokes the join handler. +- `TestNewCmdJoin_HelpDoesNotWarn`: `register --help` writes no deprecation warning. +- Table-driven `TestNewCmdJoin_LegacySSHPortFailsBeforeSideEffects` for `join --ssh-port 22`, `join -p 22`, `register --ssh-port 22`, and `register -p 22`; assert the exact migration error and zero platform, sudo, auth, NetBird, RPC, and persistence calls. +- `TestRunJoin_InteractivePromptsOnlyForMembership`: record prompts and assert there is no SSH or port prompt. +- `TestRunJoin_DoesNotOpenPortOrGrantSSH`: a successful join performs AddNode/save/setup but zero OpenPort and GrantNodeSSHAccess calls, and output contains `brev enable-ssh`. +- `Test_LoadRegistration_FailsWhenMissing`: assert the error contains `brev join` and not `brev register`. + +- [ ] **Step 2: Run the new tests and observe the expected failure** + +```bash +go test ./pkg/cmd/register ./pkg/cmd -run 'Test(NewCmdJoin|RunJoin|LoadRegistration|NewBrevCommand_BYON)' -count=1 +``` + +Expected: FAIL because `NewCmdJoin` and the canonical root command do not exist and registration still owns SSH. + +- [ ] **Step 3: Rename the user-facing registration orchestration** + +Keep package and storage terminology intact, but rename these symbols: + +```go +type joinOpts struct { + interactive bool + name string + orgName string + skipConfirm bool +} + +type joinPrompter interface { + terminal.Confirmer + terminal.Selector + Input(terminal.PromptContent) string +} + +type joinDeps struct { + platform externalnode.PlatformChecker + prompter joinPrompter + gater sudo.Gater + netbird NetBirdManager + setupRunner SetupRunner + nodeClients externalnode.NodeClientFactory + hardwareProfiler HardwareProfiler + registrationStore RegistrationStore +} + +func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command +func runJoin(ctx context.Context, t *terminal.Terminal, store RegisterStore, opts joinOpts, deps joinDeps) error +func runJoinSteps(ctx context.Context, t *terminal.Terminal, store RegisterStore, name string, org *entity.Organization, deps joinDeps) error +``` + +Add `TerminalPrompter.Input` as a thin wrapper over `terminal.PromptGetInput`, consolidate the confirmer/selector/input dependency behind `joinPrompter`, and update existing tests/mocks to the new names. `defaultJoinDeps` carries forward the current real platform, sudo gate, NetBird, setup runner, node client, hardware profiler, and registration store. + +Use canonical Cobra metadata: + +```go +Use: "join", +Aliases: []string{"register"}, +Short: "Join this device to a Brev network", +Args: cobra.NoArgs, +``` + +Keep `--name/-n`, `--org/-o`, and `--approve`. Bind `--ssh-port/-p` only as a hidden integer compatibility flag. In `RunE`, before constructing dependencies or calling `runJoin`, use `cmd.Flags().Changed("ssh-port")` so explicit zero also fails: + +```go +if cmd.CalledAs() == "register" { + fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev register" is deprecated; use "brev join" instead.`) + fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer enables SSH; run "brev enable-ssh" separately.`) +} +if cmd.Flags().Changed("ssh-port") { + return fmt.Errorf("--ssh-port is no longer supported by brev join or brev register; run brev join, then run brev enable-ssh on the joined machine") +} +``` + +Compute interactive mode only from `nameFlag == "" && orgFlag == ""`. Update long help, examples, confirmation text, progress, success text, Linux-platform error, and rejoin guidance to `join`/`leave` terminology. + +- [ ] **Step 4: Delete the SSH tail from join** + +Delete `sshPort` from options and remove: + +- the SSH enable confirmation; +- `user.Current()` from the join path; +- `grantSSHAccessWithPort` and `grantSSHAccess` from `register.go`; +- the registration-only SSH retry/OpenPort test cases now duplicated by `sshkeys` and `sshkeys_port_resolve` coverage. + +Change the successful tail to: + +```go +if err := runJoinSteps(ctx, t, s, name, org, deps); err != nil { + return err +} +t.Vprint("") +t.Vprint("SSH access was not enabled. To enable it for your user, run: brev enable-ssh") +return nil +``` + +Call `s.GetCurrentUser()` for authentication without retaining a `brevUser`, because membership setup no longer grants SSH. + +- [ ] **Step 5: Update recovery guidance and root wiring** + +Change the missing-registration error to: + +```go +return nil, breverrors.New("device registration not found, run 'brev join' first") +``` + +In `pkg/cmd/cmd.go`, register only `register.NewCmdJoin(t, externalNodeCmdStore)`. Add `TestNewBrevCommand_BYONCommandSurface` to prove `join` exists once and `register` resolves to the same pointer rather than a separately registered command. + +- [ ] **Step 6: Run focused tests and format** + +```bash +gofmt -w pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +go test ./pkg/cmd/register ./pkg/cmd -run 'Test(NewCmdJoin|RunJoin|LoadRegistration|NewBrevCommand_BYON)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +git commit -m "feat: separate network join from SSH enablement" +``` + + +--- + +## Task 3: Require Joined, Connected Membership Before `enable-ssh` + +**Files:** + +- Create: `pkg/cmd/register/node.go` +- Create: `pkg/cmd/register/node_test.go` +- Modify: `pkg/cmd/enablessh/enablessh.go` +- Modify: `pkg/cmd/enablessh/enablessh_test.go` + +- [ ] **Step 1: Write shared-node lookup tests** + +Add `TestFetchRegisteredNode_Success`, `TestFetchRegisteredNode_RPCError`, and `TestFetchRegisteredNode_NilNodeIsError`. The helper contract is: + +```go +func FetchRegisteredNode( + ctx context.Context, + nodeClients externalnode.NodeClientFactory, + tokenProvider externalnode.TokenProvider, + reg *DeviceRegistration, +) (*nodev1.ExternalNode, error) +``` + +Assert the request contains both `ExternalNodeId` and `OrganizationId`, RPC errors retain `error retrieving joined node` context, and a response with no node returns a nonnil error. + +- [ ] **Step 2: Write full enable orchestration tests** + +Introduce fakes for platform, registration store, connector, provisioner, and store. Record operation order and add: + +- `TestNewCmdEnableSSH_RejectsPositionalArguments`. +- `TestRunEnableSSH_MissingRegistrationDirectsUserToJoin`: exact guidance, no auth, node lookup, tunnel, or provisioner call. +- `TestRunEnableSSH_MissingBackendNodeDoesNotConnectOrProvision`. +- `TestRunEnableSSH_ConnectedTunnelProvisionsSSH`: order is platform, registration, auth, node, tunnel, provision. +- `TestRunEnableSSH_ReconnectsBeforeProvisioning`: the fake connector changes disconnected to connected and provision runs after it. +- `TestRunEnableSSH_TunnelFailureDoesNotProvision`. +- `TestRunEnableSSH_UnconfirmedTunnelDoesNotProvision`. +- `TestRunEnableSSH_NeverAddsNode`: the fake node service fails the test if `AddNode` is called. + +The injected mutation boundary should be: + +```go +type sshAccessProvisioner interface { + Provision( + context.Context, + *terminal.Terminal, + externalnode.TokenProvider, + *register.DeviceRegistration, + *entity.User, + *nodev1.ExternalNode, + ) error +} +``` + +- [ ] **Step 3: Run the tests and observe failure** + +```bash +go test ./pkg/cmd/register ./pkg/cmd/enablessh -run 'Test(FetchRegisteredNode|NewCmdEnableSSH|RunEnableSSH)' -count=1 +``` + +Expected: FAIL because lookup is local to `enablessh` and SSH provisioning precedes a strict tunnel check. + +- [ ] **Step 4: Add the shared registered-node helper** + +Create `node.go` with the signature above. Build the existing `GetNodeRequest`, wrap RPC failure, and reject `resp == nil`, `resp.Msg == nil`, or `resp.Msg.GetExternalNode() == nil` with: + +```text +registered node was not returned by Brev; run "brev leave" and "brev join" to repair membership +``` + +Delete the private `fetchRegisteredNode` from `enablessh.go` and use the shared helper in both SSH commands added by this plan. + +- [ ] **Step 5: Refactor enablement behind a post-connect provisioner** + +Use these dependencies: + +```go +type enableSSHDeps struct { + platform externalnode.PlatformChecker + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore + tunnel register.NetBirdConnector + provisioner sshAccessProvisioner +} + +type defaultSSHAccessProvisioner struct { + prompter terminal.Selector + nodeClients externalnode.NodeClientFactory +} +``` + +`defaultEnableSSHDeps` must use `register.LinuxPlatform{}`, `register.NewFileRegistrationStore()`, `register.Netbird{}`, and a `defaultSSHAccessProvisioner` built with the same real node-client factory and terminal selector. + +Move current Linux-user lookup, `checkSSHDaemon`, `ResolveSSHAccessPort`, and `SetupAndRegisterNodeSSHAccess` into `defaultSSHAccessProvisioner.Provision`. Keep the success output in `runEnableSSH` after the provisioner returns. + +Implement this exact mutation boundary in `runEnableSSH`: + +```go +exists, err := deps.registrationStore.Exists() +if err != nil { + return fmt.Errorf("check joined-device registration: %w", err) +} +if !exists { + return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) +} + +reg, err := deps.registrationStore.Load() +if err != nil { + return fmt.Errorf("read joined-device registration: %w", err) +} +brevUser, err := s.GetCurrentUser() +if err != nil { + return breverrors.WrapAndTrace(err) +} +node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, s, reg) +if err != nil { + return fmt.Errorf("enable SSH failed: %w", err) +} +if err := deps.tunnel.EnsureConnected(ctx); err != nil { + return fmt.Errorf("enable SSH requires a connected Brev tunnel: %w", err) +} +if err := deps.provisioner.Provision(ctx, t, s, reg, brevUser, node); err != nil { + return fmt.Errorf("enable SSH failed: %w", err) +} +``` + +Add `Args: cobra.NoArgs` and update help to say “joined node.” A healthy tunnel performs no privileged command; only `Netbird.EnsureConnected` invokes interactive `sudo` when it must start the service or run `netbird up`. Do not add an unconditional sudo gate, AddNode, organization, or registration-save behavior. + +- [ ] **Step 6: Run tests and format** + +```bash +gofmt -w pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go +go test ./pkg/cmd/register ./pkg/cmd/enablessh -run 'Test(FetchRegisteredNode|NewCmdEnableSSH|RunEnableSSH)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go +git commit -m "feat: require joined tunnel before enabling SSH" +``` + + +--- + +## Task 4: Build a Privileged, Node-Wide Brev Key Cleanup Boundary + +**Files:** + +- Modify: `pkg/cmd/register/sshkeys.go` +- Modify: `pkg/cmd/register/sshkeys_test.go` +- Modify: `main.go` +- Create: `pkg/cmd/disablessh/localkeys.go` +- Create: `pkg/cmd/disablessh/localkeys_test.go` +- Create: `pkg/cmd/disablessh/localkeys_linux.go` +- Create: `pkg/cmd/disablessh/localkeys_linux_test.go` +- Create: `pkg/cmd/disablessh/localkeys_unsupported.go` +- Create: `pkg/cmd/disablessh/testdata/passwd.txt` +- Create: `pkg/cmd/disablessh/testdata/authorized_keys.before` +- Create: `pkg/cmd/disablessh/testdata/authorized_keys.after` + +The existing sudo gate only validates or refreshes credentials; it does not elevate the Go process. A node-wide sweep therefore needs a narrow privileged subprocess rather than calling `RemoveBrevAuthorizedKeys` for other users from the unprivileged CLI. + +- [ ] **Step 1: Expose and lock down the marker predicate** + +Rename the existing private helper without changing its semantics: + +```go +// IsBrevManagedAuthorizedKeysLine reports whether a line was managed by a +// current or legacy Brev CLI SSH flow. +func IsBrevManagedAuthorizedKeysLine(line string) bool { + return strings.Contains(line, BrevKeyPrefixLegacy) || strings.Contains(line, "#brev-portID:") +} +``` + +Update internal callers and add table-driven coverage for current marker, legacy marker, unrelated key, blank line, and a comment that contains neither exact marker. + +- [ ] **Step 2: Add pure account-parser and byte-filter tests** + +Create fixtures with root, two normal users, a service account, and two users sharing one home. `authorized_keys.before` must include an unrelated options-prefixed key, one current Brev marker, one legacy marker, a blank line, CRLF content, and a final newline. `authorized_keys.after` must contain every unrelated byte in the original order. + +Define the OS-neutral shapes: + +```go +const cleanupHelperArg = "__brev-disable-ssh-cleanup" + +type KeyCleanupResult struct { + AccountsScanned int `json:"accounts_scanned"` + AccountsChanged int `json:"accounts_changed"` + KeysRemoved int `json:"keys_removed"` +} + +type localAccount struct { + Username string + HomeDir string +} + +type localKeyCleaner interface { + RemoveBrevKeys(context.Context) (KeyCleanupResult, error) +} + +func parsePasswd(data []byte) ([]localAccount, error) +func stripBrevManagedAuthorizedKeyLines(data []byte) (cleaned []byte, removed int) +``` + +Add: + +- `TestParsePasswd_EnumeratesAndDeduplicatesHomes`: all account types remain; duplicate home appears once. +- `TestParsePasswd_RejectsMalformedRecord`: fewer than seven fields is an error with line context. +- `TestParsePasswd_RejectsRelativeHome`: only absolute home paths are accepted. +- `TestStripBrevManagedAuthorizedKeyLines_PreservesUnrelatedBytes`: compare exact bytes with `authorized_keys.after` and assert two removals. +- `TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes`: zero removals and byte equality. + +Implement filtering with `bytes.SplitAfter(data, []byte("\n"))`; remove the trailing `\n` and optional `\r` only for marker classification, and append every unremoved segment unchanged. This preserves blank lines, CRLF, ordering, and final-newline state. + +- [ ] **Step 3: Add aggregate-cleaner tests** + +Use injected account listing and per-account cleaning: + +```go +type systemLocalKeyCleaner struct { + listAccounts func(context.Context) ([]localAccount, error) + cleanAccount func(localAccount) (int, error) +} + +func (c systemLocalKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) +func newSystemLocalKeyCleaner() localKeyCleaner +func newPrivilegedLocalKeyCleaner() localKeyCleaner +``` + +Add `TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors`. Return failures for the first and third account, verify the second still runs, verify the result counts successful removals, and assert the combined error contains both usernames and home paths. Use `breverrors.Join` after wrapping each account failure. + +- [ ] **Step 4: Run the pure tests and observe failure** + +```bash +go test ./pkg/cmd/register ./pkg/cmd/disablessh -run 'Test(IsBrevManaged|ParsePasswd|StripBrev|SystemLocalKeyCleaner)' -count=1 +``` + +Expected: FAIL because the package and exported predicate do not exist. + +- [ ] **Step 5: Implement the OS-neutral cleanup and sudo protocol** + +Add a privileged runner with injectable seams: + +```go +type privilegedCommandRunner interface { + Output(context.Context, string, ...string) ([]byte, error) +} + +type privilegedLocalKeyCleaner struct { + geteuid func() int + executable func() (string, error) + runner privilegedCommandRunner + direct localKeyCleaner +} + +func (c privilegedLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { + if c.geteuid() == 0 { + return c.direct.RemoveBrevKeys(ctx) + } + executable, err := c.executable() + if err != nil { + return KeyCleanupResult{}, fmt.Errorf("locate Brev executable: %w", err) + } + output, err := c.runner.Output(ctx, "sudo", "-n", executable, cleanupHelperArg) + if err != nil { + return KeyCleanupResult{}, fmt.Errorf("run privileged Brev key cleanup: %w", err) + } + var result KeyCleanupResult + if err := json.Unmarshal(output, &result); err != nil { + return KeyCleanupResult{}, fmt.Errorf("decode privileged Brev key cleanup result: %w", err) + } + return result, nil +} +``` + +The real runner must use `exec.CommandContext(...).Output()` and include `*exec.ExitError.Stderr` in its returned error, but never mix stderr into the JSON stdout stream. + +Add this exported dispatcher for `main.go`: + +```go +func RunLocalKeyCleanupHelper(ctx context.Context, args []string, stdout io.Writer) (bool, error) +``` + +It returns `(false, nil)` unless the first argument exactly equals `cleanupHelperArg`. Once selected, it accepts exactly one argument, requires Linux, requires `os.Geteuid() == 0`, invokes `newSystemLocalKeyCleaner`, and JSON-encodes only `KeyCleanupResult` to stdout. It accepts no usernames, home directories, or file paths. + +Use an unexported injected variant in tests and add: + +- `TestPrivilegedLocalKeyCleaner_RootRunsDirectly`. +- `TestPrivilegedLocalKeyCleaner_UsesFixedSudoCommandWhenNotRoot`. +- `TestPrivilegedLocalKeyCleaner_RejectsInvalidJSON`. +- `TestRunLocalKeyCleanupHelper_IgnoresNormalCLIArguments`. +- `TestRunLocalKeyCleanupHelper_RejectsExtraArguments`. +- `TestRunLocalKeyCleanupHelper_RejectsNonRoot`. +- `TestRunLocalKeyCleanupHelper_EmitsJSON`. + +- [ ] **Step 6: Implement secure Linux account enumeration and file replacement** + +Put Linux implementation behind `//go:build linux`. Resolve `getent` only from fixed candidates `/usr/bin/getent` and `/bin/getent`, run `getent passwd`, and parse its stdout. A missing command, nonzero exit, or malformed record fails the sweep instead of falsely reporting completeness. + +For each deduplicated absolute home: + +1. Start from an open descriptor for `/` and walk every cleaned absolute-home component with `unix.Openat(..., unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW)`. Reject `..`, symlinks in any intermediate or final component, and non-directories; `O_NOFOLLOW` on one absolute-path open is insufficient because it protects only the final component. +2. Open literal `.ssh` from the verified home descriptor with the same directory/no-follow flags. +3. Use `unix.Fstatat` with `AT_SYMLINK_NOFOLLOW` on literal `authorized_keys` and require a regular file before opening it. Then open it with `unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK`, immediately `Fstat` the descriptor again to close the race, and reject a FIFO, device, directory, socket, or changed/non-regular target before reading. +4. Treat `ENOENT` for a home component, `.ssh`, or `authorized_keys` as zero removals. Return every other unsafe-type or path error with account and path context. +5. Read the file from its descriptor, filter it, and skip all writes when no markers match. +6. Record the original `Stat_t.Uid`, `Stat_t.Gid`, and permission bits. +7. Create a random `authorized_keys.brev-cleanup-*` file in the already-open `.ssh` directory with `O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW`. +8. Write all cleaned bytes, `Fchown` to the original UID/GID, then `Fchmod` to the original permission bits (chown can clear mode bits), `Fsync`, atomically `Renameat` over literal `authorized_keys`, and `Fsync` the directory. +9. Close descriptors on every path and unlink an unrenamed temporary file on failure. + +Do not recurse, follow symlinks, evaluate shell text, or accept a path from the caller. The non-Linux file must return `brev disable-ssh local cleanup is only supported on Linux` while preserving compilation on Darwin. + +- [ ] **Step 7: Add Linux filesystem tests** + +Behind `//go:build linux`, add: + +- `TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwnership`: use a mode with the setgid bit and verify the full promised mode after the required `Fchown`-then-`Fchmod` order. +- `TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite`: compare inode before/after to prove no replacement. +- `TestSystemAuthorizedKeysCleaner_MissingSSHDirectoryIsSuccess`. +- `TestSystemAuthorizedKeysCleaner_MissingAuthorizedKeysIsSuccess`. +- `TestSystemAuthorizedKeysCleaner_RejectsSSHDirectorySymlink`. +- `TestSystemAuthorizedKeysCleaner_RejectsAuthorizedKeysSymlink`. +- `TestSystemAuthorizedKeysCleaner_RejectsIntermediateHomeSymlink`. +- `TestSystemAuthorizedKeysCleaner_RejectsFIFOWithoutBlocking`. +- `TestSystemAuthorizedKeysCleaner_RejectsNonRegularAuthorizedKeys`. + +Use `t.TempDir()` only; never point tests at a real account home. Ownership assertions may compare unchanged UID/GID without changing them. + +- [ ] **Step 8: Dispatch the helper before normal CLI initialization** + +At the top of `main`, before Sentry, analytics, stores, version checks, or Cobra setup: + +```go +handled, err := disablessh.RunLocalKeyCleanupHelper(context.Background(), os.Args[1:], os.Stdout) +if handled { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return +} +``` + +This prevents the root subprocess from logging in, creating analytics events, or running a second backend operation. + +- [ ] **Step 9: Run tests, format, and verify both build targets** + +```bash +gofmt -w main.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go +go test ./pkg/cmd/register ./pkg/cmd/disablessh -run 'Test(IsBrevManaged|ParsePasswd|StripBrev|SystemLocalKeyCleaner|PrivilegedLocalKeyCleaner|RunLocalKeyCleanupHelper)' -count=1 +go test . -run '^$' +GOOS=linux GOARCH=amd64 go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh +GOOS=linux GOARCH=amd64 go build -o /tmp/brev-cli-linux . +``` + +Expected: OS-neutral tests PASS locally, the Darwin root executable compiles with the early dispatcher, and both the Linux package tests and Linux root executable cross-compile. Run the tagged filesystem tests on a Linux runner during final verification. + +- [ ] **Step 10: Commit** + +```bash +git add main.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go pkg/cmd/disablessh/testdata +git commit -m "feat: add privileged node-wide Brev key cleanup" +``` + + +--- + +## Task 5: Add Backend-First, Node-Wide `disable-ssh` + +**Files:** + +- Create: `pkg/cmd/disablessh/disablessh.go` +- Create: `pkg/cmd/disablessh/disablessh_test.go` +- Modify: `pkg/cmd/cmd.go` +- Modify: `pkg/cmd/cmd_test.go` + +- [ ] **Step 1: Define orchestration seams and write command tests** + +Use these narrow command dependencies: + +```go +type DisableSSHStore interface { + GetCurrentUser() (*entity.User, error) + GetAccessToken() (string, error) +} + +type disableSSHDeps struct { + platform externalnode.PlatformChecker + confirmer terminal.Confirmer + gater sudo.Gater + tunnel register.NetBirdConnector + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore + keyCleaner localKeyCleaner +} + +func NewCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore) *cobra.Command +func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableSSHDeps) *cobra.Command +func runDisableSSH( + ctx context.Context, + t *terminal.Terminal, + warnings io.Writer, + store DisableSSHStore, + deps disableSSHDeps, + skipConfirm bool, +) error +``` + +`defaultDisableSSHDeps` must use `register.LinuxPlatform{}`, `register.TerminalPrompter{}`, `sudo.Default`, `register.Netbird{}`, `register.DefaultNodeClientFactory{}`, `register.NewFileRegistrationStore()`, and `newPrivilegedLocalKeyCleaner()`. The public constructor must close over these defaults; tests call the injected constructor. + +Add `TestNewCmdDisableSSH_CommandSurface` and `TestNewCmdDisableSSH_RejectsArguments`. Assert `Use: "disable-ssh"`, `Args: cobra.NoArgs`, configuration annotation, and `--approve`. + +- [ ] **Step 2: Write state-machine tests before implementation** + +Use a fake ConnectRPC service that records GetNode, RevokeNodeSSHAccess, RemoveNode, ClosePort, and AddNode calls. Add: + +- `TestRunDisableSSH_MissingRegistrationDoesNotAuthenticateOrCallRPC`. +- `TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup`. +- `TestRunDisableSSH_ApproveSkipsConfirmationButPrintsSafetyWarning`. +- `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts`. +- `TestRunDisableSSH_IgnoresNilAccessEntries`. +- `TestRunDisableSSH_ConnectsBeforeFirstRevocation`. +- `TestRunDisableSSH_RevokesEveryExactTupleSequentiallyOnce`. +- `TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors`. +- `TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup`. +- `TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys`. +- `TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership`. +- `TestRunDisableSSH_DoesNotRemoveNodeClosePortUninstallNetBirdOrDeleteRegistration`. + +Use access records with repeated Linux accounts so the warning asserts both total-grant and distinct-account counts. For exact tuple assertions, compare: + +```go +&nodev1.RevokeNodeSSHAccessRequest{ + ExternalNodeId: reg.ExternalNodeID, + PortId: access.GetPortId(), + UserId: access.GetUserId(), + LinuxUser: access.GetLinuxUser(), +} +``` + +- [ ] **Step 3: Run the new tests and observe failure** + +```bash +go test ./pkg/cmd/disablessh ./pkg/cmd -run 'Test(NewCmdDisableSSH|RunDisableSSH|NewBrevCommand_BYON)' -count=1 +``` + +Expected: FAIL because the public command does not exist. + +- [ ] **Step 4: Implement preflight and confirmation** + +Configure the command as: + +```go +Use: "disable-ssh", +Short: "Disable all Brev-managed SSH access on this node", +Args: cobra.NoArgs, +DisableFlagsInUseLine: true, +Annotations: map[string]string{"configuration": ""}, +``` + +Implement preflight in this order: + +1. Linux compatibility. +2. `registrationStore.Exists`; if false, return `This machine has not joined a Brev network; run "brev join" first.` +3. Load registration. +4. Authenticate with `GetCurrentUser`. +5. Fetch the registered node through `register.FetchRegisteredNode`. +6. Copy every non-nil entry from `node.GetSshAccess()` into a new slice before mutation; nil protobuf entries are not active grants. +7. Print node, total grants, distinct Linux accounts, and the warning that existing sessions are not forcibly terminated. +8. Confirm unless `--approve`; a cancellation returns nil. +9. Only after confirmation, call the sudo gate with reason `Node-wide Brev SSH cleanup`. + +The active-session and node-wide-scope warnings must use the supplied stderr writer even with `--approve`. + +- [ ] **Step 5: Implement backend-first revocation and conditional cleanup** + +If the access snapshot is nonempty, call `deps.tunnel.EnsureConnected(ctx)` before creating any revoke request. If it fails, return without local cleanup. When the snapshot is empty, skip the tunnel entirely. + +Revoke sequentially in slice order. Continue after errors and collect each with exact context: + +```go +revokeErrs = append(revokeErrs, fmt.Errorf( + "revoke SSH access for user %q, Linux account %q, port %q: %w", + access.GetUserId(), + access.GetLinuxUser(), + access.GetPortId(), + err, +)) +``` + +After the loop: + +```go +if err := breverrors.Join(revokeErrs...); err != nil { + return fmt.Errorf("disable SSH backend cleanup incomplete: %w", err) +} +result, err := deps.keyCleaner.RemoveBrevKeys(ctx) +if err != nil { + return fmt.Errorf("disable SSH local key cleanup incomplete: %w", err) +} +``` + +Do not suppress arbitrary RPC NotFound errors: the backend revoke operation itself is already idempotent for an absent exact tuple, while a transport NotFound can mean a missing node or port and must not authorize a broad local sweep. + +Print success only after the cleaner succeeds, including `result.KeysRemoved` and `result.AccountsChanged`. Keep membership and registration intact on every outcome. + +- [ ] **Step 6: Wire the command exactly once at the root** + +Import `pkg/cmd/disablessh` in `pkg/cmd/cmd.go` and add: + +```go +cmd.AddCommand(disablessh.NewCmdDisableSSH(t, externalNodeCmdStore)) +``` + +Extend `TestNewBrevCommand_BYONCommandSurface` to assert one canonical `disable-ssh` command and no alias. + +- [ ] **Step 7: Run tests and format** + +```bash +gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +go test ./pkg/cmd/disablessh ./pkg/cmd -run 'Test(NewCmdDisableSSH|RunDisableSSH|NewBrevCommand_BYON)' -count=1 +go test -race ./pkg/cmd/disablessh -count=1 +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +git commit -m "feat: add node-wide disable-ssh command" +``` + +--- + +## Task 6: Make `leave` Canonical and Membership-Only + +**Files:** + +- Modify: `pkg/cmd/deregister/deregister.go` +- Modify: `pkg/cmd/deregister/deregister_test.go` +- Modify: `pkg/cmd/cmd.go` +- Modify: `pkg/cmd/cmd_test.go` + +The current backend masks a missing `GetNode` as Connect `PermissionDenied`, while `RemoveNode` itself is idempotent for a missing node. Do not downgrade `PermissionDenied`: preserve the approved stop-on-lookup-error contract by performing leave preflight through organization-scoped `ListNodes` and matching the persisted external-node ID. An absent ID is a retryable missing node only when the response is complete; if the response has a next-page token, stop because the current backend ignores requested page parameters and the CLI cannot safely prove absence. + +- [ ] **Step 1: Write canonical command and alias tests** + +Add: + +- `TestNewCmdLeave_CommandSurface`: `Use: "leave"`, alias `deregister`, `Args: cobra.NoArgs`, configuration annotation, and `--approve`. +- `TestNewCmdLeave_DeregisterAliasWarnsOnExecution`: assert the approved two-line warning on Cobra stderr. +- `TestNewCmdLeave_HelpDoesNotWarn`. +- `TestNewCmdLeave_RejectsArguments` for both canonical and alias invocations. + +The execution-only alias warning is: + +```go +if cmd.CalledAs() == "deregister" { + fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev deregister" is deprecated; use "brev leave" instead.`) + fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer removes SSH keys; run "brev disable-ssh" before leaving if you want to remove Brev-managed SSH access.`) +} +``` + +- [ ] **Step 2: Write leave state-machine tests** + +Use a shared event recorder across registration, auth, node RPC, confirmation, sudo, NetBird, and registration deletion. Add: + +- `TestRunLeave_RemainingGrantsWarnButDoNotBlock`: known access records print the retained-host-key warning and cancellation guidance. +- `TestRunLeave_ApproveSkipsConfirmationButNotWarnings`. +- `TestRunLeave_CancelStopsBeforeSudoAndMutation`. +- `TestRunLeave_OrderIsRemoveNodeUninstallDeleteRegistration`. +- `TestRunLeave_RemoveNodeFailureStopsLocalTeardown`. +- `TestRunLeave_CompleteNodeListWithoutRegisteredIDAllowsAuthoritativeRemoveRetry`. +- `TestRunLeave_ListPermissionDeniedStopsBeforeConfirmationAndMutation`. +- `TestRunLeave_RegisteredIDAbsentFromIncompleteListStopsBeforeMutation`. +- `TestRunLeave_OtherLookupFailureStopsBeforeConfirmationAndMutation`. +- `TestRunLeave_RemoveNodeNotFoundIsAccepted`. +- `TestRunLeave_NetBirdFailureReturnsErrorAndRetainsRegistration`. +- `TestRunLeave_RegistrationDeleteFailureReturnsErrorAndNoSuccess`. +- `TestRunLeave_NeverRevokesSSHOrEditsAuthorizedKeys`. + +Assert warning text is written through the injected stderr writer, including with `--approve`. Assert no success string is present for every failure. + +- [ ] **Step 3: Run the new tests and observe failure** + +```bash +go test ./pkg/cmd/deregister ./pkg/cmd -run 'Test(NewCmdLeave|RunLeave|NewBrevCommand_BYON)' -count=1 +``` + +Expected: FAIL because `leave` does not exist and deregistration still edits the invoking user's key file. + +- [ ] **Step 4: Rename the public orchestration and remove SSH dependencies** + +Keep the package name, but use: + +```go +type LeaveStore interface { + GetCurrentUser() (*entity.User, error) + GetAccessToken() (string, error) +} + +type netBirdUninstaller interface { + Uninstall() error +} + +type leaveDeps struct { + platform externalnode.PlatformChecker + confirmer terminal.Confirmer + gater sudo.Gater + netbird netBirdUninstaller + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore +} + +func NewCmdLeave(t *terminal.Terminal, store LeaveStore) *cobra.Command +func runLeave( + ctx context.Context, + t *terminal.Terminal, + warnings io.Writer, + store LeaveStore, + deps leaveDeps, + skipConfirm bool, +) error +``` + +`defaultLeaveDeps` must use `register.LinuxPlatform{}`, `register.TerminalPrompter{}`, `sudo.Default`, `register.Netbird{}`, `register.DefaultNodeClientFactory{}`, and `register.NewFileRegistrationStore()`. + +Delete `SSHKeyRemover`, `brevSSHKeyRemover`, `os/user`, and all direct key-removal output. Use canonical Cobra metadata and retain only `--approve`. + +- [ ] **Step 5: Implement read-only preflight and warnings** + +After Linux check, registration load, and authentication, call a private organization-scoped helper: + +```go +func lookupJoinedNodeForLeave( + ctx context.Context, + client nodev1connect.ExternalNodeServiceClient, + reg *register.DeviceRegistration, +) (node *nodev1.ExternalNode, missing bool, err error) { + resp, err := client.ListNodes(ctx, connect.NewRequest(&nodev1.ListNodesRequest{ + OrganizationId: reg.OrgID, + })) + if err != nil { + return nil, false, fmt.Errorf("list organization nodes: %w", err) + } + if resp == nil || resp.Msg == nil { + return nil, false, fmt.Errorf("list organization nodes: empty response") + } + for _, candidate := range resp.Msg.GetItems() { + if candidate != nil && candidate.GetExternalNodeId() == reg.ExternalNodeID { + return candidate, false, nil + } + } + if resp.Msg.GetNextPageToken() != "" { + return nil, false, fmt.Errorf("registered node was not in the returned page and node listing is incomplete") + } + return nil, true, nil +} +``` + +Any ListNodes error, including `PermissionDenied`, or an incomplete response without the registered ID returns `inspect joined node before leaving` before confirmation, sudo, or mutation. When the complete list proves the node is absent, continue with no access snapshot and write that the backend node is already absent but tagged host keys may remain. This is the idempotent retry path; authoritative `RemoveNode` is still called defensively. + +Always write this safety warning before confirmation: + +```text +Leaving removes the Brev tunnel and may interrupt commands using Brev SSH. Run this locally or through out-of-band access. +``` + +When known non-nil grants remain, include total grant and distinct Linux-account counts and this action: + +```text +Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed. +``` + +Do not block on grants. Confirm through `terminal.Confirmer` unless `--approve` was supplied, then call the sudo gate only after confirmation so cancellation has no elevation side effect. + +- [ ] **Step 6: Implement authoritative, retry-safe teardown** + +Call operations strictly in this order: + +```go +_, err := client.RemoveNode(ctx, connect.NewRequest(&nodev1.RemoveNodeRequest{ + ExternalNodeId: reg.ExternalNodeID, +})) +if err != nil && connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("leave Brev network: remove node: %w", err) +} +if err := deps.netbird.Uninstall(); err != nil { + return fmt.Errorf("leave Brev network: uninstall tunnel: %w", err) +} +if err := deps.registrationStore.Delete(); err != nil { + return fmt.Errorf("leave Brev network: delete local registration: %w", err) +} +``` + +Do not delete registration when RemoveNode or Uninstall fails. Return the Delete error rather than printing a warning and false completion. Only after all three operations succeed, print `Left the Brev network.` + +- [ ] **Step 7: Update root wiring and command-surface tests** + +Replace `deregister.NewCmdDeregister` with `deregister.NewCmdLeave`. Extend the root test to prove `leave` exists once and `deregister` resolves to the exact same command pointer, not an independently registered command. + +- [ ] **Step 8: Run tests and format** + +```bash +gofmt -w pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +go test ./pkg/cmd/deregister ./pkg/cmd -run 'Test(NewCmdLeave|RunLeave|NewBrevCommand_BYON)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +git commit -m "feat: separate network leave from SSH cleanup" +``` + +--- + +## Task 7: Document the Explicit Workflows and Verify the Complete Change + +**Files:** + +- Create: `docs/BYON.md` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `.agents/skills/brev-cli/SKILL.md` +- Modify: `.agents/skills/brev-cli/reference/commands.md` +- Modify: all Go files touched in Tasks 1–6 if verification exposes formatting or lint defects + +- [ ] **Step 1: Write the BYON guide** + +Create `docs/BYON.md` with these explicit workflows: + +```text +Join networking only: + brev join + +Optionally enable SSH for yourself, then grant collaborators individually: + brev enable-ssh + brev grant-ssh + +Remove all Brev-managed SSH credentials, then retire membership: + brev disable-ssh + brev leave +``` + +Document that: + +- `register` and `deregister` are deprecated aliases. +- `enable-ssh` requires prior join and reconnects an existing disconnected tunnel. +- `disable-ssh` is node-wide, leaves ports allocated, does not stop sshd, and does not terminate active SSH sessions. +- `leave` removes the VPN route/backend node but does not remove physical host keys. +- `leave` currently preserves the old behavior of uninstalling NetBird even if the user installed it before Brev; tracking install ownership is a follow-up. + +Link this guide from `README.md` directly below the existing NVIDIA/Brev documentation link. + +- [ ] **Step 2: Update shipped CLI-skill guidance** + +Add a “BYON Network and SSH Commands” section to `.agents/skills/brev-cli/reference/commands.md` before configuration commands. Document `join`, `register`, `enable-ssh`, `grant-ssh`, `revoke-ssh`, `disable-ssh`, `leave`, and `deregister`, including the two canonical multi-command workflows. + +Update `.agents/skills/brev-cli/SKILL.md` so no instruction implies join automatically enables SSH. Keep cloud-instance commands outside this change untouched. + +- [ ] **Step 3: Add release notes** + +Under `CHANGELOG.md` Unreleased, record: + +- Added: `join`, `leave`, and node-wide `disable-ssh`. +- Changed: `join` no longer enables SSH; `enable-ssh` requires and reconnects existing membership. +- Deprecated: `register` and `deregister` remain aliases and warn on stderr. +- Migration: scripts using `--ssh-port` must run `brev join` followed by `brev enable-ssh`. + +- [ ] **Step 4: Search for stale user-facing terminology** + +Run: + +```bash +rg -n "brev (register|deregister)|--ssh-port|Registering your device|Deregistering your device" README.md CHANGELOG.md docs .agents/skills pkg/cmd +``` + +Expected: remaining `register`/`deregister` references are alias documentation, deprecation tests, internal persistence/backend terminology, or deliberate compatibility errors. No public example presents either alias as canonical, and no join help presents `--ssh-port` as supported. + +- [ ] **Step 5: Verify focused behavior** + +Run: + +```bash +go test ./pkg/cmd/register ./pkg/cmd/enablessh ./pkg/cmd/disablessh ./pkg/cmd/deregister ./pkg/cmd -count=1 +go test -race ./pkg/cmd/disablessh -count=1 +``` + +Expected: PASS. If the restricted sandbox denies an existing `httptest` listener, rerun outside the sandbox and record that environment distinction. + +- [ ] **Step 6: Format and lint the touched command packages** + +Run: + +```bash +gofmt -w main.go pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go +golangci-lint run ./pkg/cmd/... ./pkg/sudo/... +``` + +Expected: PASS. Fix only defects caused by this branch; do not absorb unrelated lint churn. + +- [ ] **Step 7: Verify cross-platform compilation and Linux-only tests** + +On the current Darwin host: + +```bash +GOOS=linux GOARCH=amd64 go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh +GOOS=linux GOARCH=amd64 go build -o /tmp/brev-cli-linux . +``` + +On a Linux runner or Linux development host: + +```bash +go test -race ./pkg/cmd/disablessh -run 'TestSystemAuthorizedKeysCleaner' -count=1 +``` + +Expected: Linux package and root-command cross-builds PASS and descriptor/symlink tests PASS on Linux. + +- [ ] **Step 8: Inspect the rendered command surface** + +Run: + +```bash +go run . --help +go run . join --help +go run . leave --help +go run . enable-ssh --help +go run . disable-ssh --help +``` + +Expected: root help shows canonical `join`, `leave`, `enable-ssh`, and `disable-ssh`; aliases are not independent top-level entries; canonical help emits no warning; `--ssh-port` is hidden. + +- [ ] **Step 9: Attempt repository-wide verification and classify baseline failures** + +Run: + +```bash +go test ./pkg/... -count=1 +go test ./... -count=1 +``` + +Expected: all affected command packages pass. If the known macOS-only baseline failures recur in Linux e2e setup, JetBrains Gateway detection, or WSL store tests, capture their exact package/test names and confirm no affected BYON package failed. + +- [ ] **Step 10: Review scope and diff** + +Run: + +```bash +git status --short +git diff --check +git diff --stat 2954f39e..HEAD +git log --oneline 2954f39e..HEAD +``` + +Confirm there is no backend/proto change, no port closure, no sshd stop, no implicit AddNode from SSH commands, no SSH cleanup from leave, no node removal from disable, and no unrelated user work. + +- [ ] **Step 11: Commit documentation and final verification fixes** + +```bash +git add README.md CHANGELOG.md docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md +git commit -m "docs: explain explicit BYON network and SSH flows" +``` + +If verification required a source/test correction after Task 6, include only that tightly related correction in this commit and describe it in the commit body. + +--- From 19a57b53201c86299a7da1b8ba04abde4a62e2df Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:17:57 -0700 Subject: [PATCH 04/23] feat: require confirmed Brev tunnel connectivity --- pkg/cmd/deregister/deregister_test.go | 6 +- pkg/cmd/register/providers.go | 122 +++++++++++++++--- pkg/cmd/register/providers_test.go | 174 ++++++++++++++++++++++++++ pkg/cmd/register/register.go | 34 ++--- pkg/cmd/register/register_test.go | 50 +++++++- 5 files changed, 342 insertions(+), 44 deletions(-) create mode 100644 pkg/cmd/register/providers_test.go diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index 95c5ac99..63ff9150 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -96,9 +96,9 @@ type mockNetBirdManager struct { err error } -func (m *mockNetBirdManager) Install() error { return m.err } -func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err } -func (m *mockNetBirdManager) EnsureRunning() error { return m.err } +func (m *mockNetBirdManager) Install() error { return m.err } +func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err } +func (m *mockNetBirdManager) EnsureConnected(context.Context) error { return m.err } type mockNodeClientFactory struct { serverURL string diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index a3c27bd5..38020d65 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -1,10 +1,13 @@ package register import ( + "context" "fmt" + "os" "os/exec" "runtime" "strings" + "time" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" @@ -35,37 +38,126 @@ func (TerminalPrompter) Select(label string, items []string) string { }) } -// Netbird handles NetBird installation and uninstallation. -type Netbird struct{} +const ( + defaultNetBirdConnectTimeout = 30 * time.Second + defaultNetBirdPollInterval = 500 * time.Millisecond +) + +type netBirdCommandRunner interface { + Output(context.Context, string, ...string) ([]byte, error) + Run(context.Context, string, ...string) error +} + +type execNetBirdCommandRunner struct{} + +func (execNetBirdCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +func (execNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Netbird handles NetBird installation and connectivity. +type Netbird struct { + runner netBirdCommandRunner + connectTimeout time.Duration + pollInterval time.Duration +} func (Netbird) Install() error { return InstallNetbird() } func (Netbird) Uninstall() error { return UninstallNetbird() } -// EnsureRunning checks if the netbird systemd service is active and attempts -// to start it if it is not. It also checks the netbird peer connection status -// and runs "netbird up" if the peer is disconnected. -func (Netbird) EnsureRunning() error { - out, err := exec.Command("systemctl", "is-active", "netbird").Output() //nolint:gosec // fixed service name +func (n Netbird) commandRunner() netBirdCommandRunner { + if n.runner != nil { + return n.runner + } + return execNetBirdCommandRunner{} +} + +func (n Netbird) connectionTimeout() time.Duration { + if n.connectTimeout > 0 { + return n.connectTimeout + } + return defaultNetBirdConnectTimeout +} + +func (n Netbird) connectionPollInterval() time.Duration { + if n.pollInterval > 0 { + return n.pollInterval + } + return defaultNetBirdPollInterval +} + +// EnsureConnected ensures the local service is active and confirms that its +// management connection is established before returning. +func (n Netbird) EnsureConnected(ctx context.Context) error { + runner := n.commandRunner() + out, err := runner.Output(ctx, "systemctl", "is-active", "netbird") if err != nil || strings.TrimSpace(string(out)) != "active" { - if startErr := exec.Command("sudo", "systemctl", "start", "netbird").Run(); startErr != nil { //nolint:gosec // fixed service name + if startErr := runner.Run(ctx, "sudo", "systemctl", "start", "netbird"); startErr != nil { return fmt.Errorf("failed to start Brev tunnel service: %w", startErr) } } - statusOut, err := exec.Command("netbird", "status").Output() //nolint:gosec // fixed command - if err != nil { - // Service is running, just can't confirm peer status. + statusOut, statusErr := runner.Output(ctx, "netbird", "status") + if statusErr == nil && netbirdManagementConnected(string(statusOut)) { return nil } - if netbirdManagementConnected(string(statusOut)) { + if upErr := runner.Run(ctx, "sudo", "netbird", "up"); upErr != nil { + return fmt.Errorf("failed to reconnect Brev tunnel: %w", upErr) + } + + confirmationCtx, cancel := context.WithTimeout(ctx, n.connectionTimeout()) + defer cancel() + lastStatusErr := statusErr + checkStatus := func() bool { + statusOut, err := runner.Output(confirmationCtx, "netbird", "status") + if err != nil { + lastStatusErr = err + return false + } + return netbirdManagementConnected(string(statusOut)) + } + + if checkStatus() { return nil } - if upErr := exec.Command("sudo", "netbird", "up").Run(); upErr != nil { //nolint:gosec // fixed command - return fmt.Errorf("failed to reconnect Brev tunnel: %w", upErr) + ticker := time.NewTicker(n.connectionPollInterval()) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-confirmationCtx.Done(): + if lastStatusErr != nil { + return fmt.Errorf("Brev tunnel connection was not confirmed: %w", lastStatusErr) + } + return fmt.Errorf("Brev tunnel connection was not confirmed") + case <-ticker.C: + if checkStatus() { + return nil + } + } + } +} + +// netbirdManagementConnected parses "netbird status" output and returns true +// when the Management line reports "Connected". +func netbirdManagementConnected(statusOutput string) bool { + for _, line := range strings.Split(statusOutput, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Management:") { + return strings.TrimSpace(strings.TrimPrefix(line, "Management:")) == "Connected" + } } - return nil + return false } // ShellSetupRunner runs setup scripts via shell. diff --git a/pkg/cmd/register/providers_test.go b/pkg/cmd/register/providers_test.go new file mode 100644 index 00000000..c1ef9ff6 --- /dev/null +++ b/pkg/cmd/register/providers_test.go @@ -0,0 +1,174 @@ +package register + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +type netBirdCall struct { + name string + args []string +} + +type netBirdResult struct { + output []byte + err error +} + +type fakeNetBirdCommandRunner struct { + results []netBirdResult + fallback netBirdResult + calls []netBirdCall +} + +func (f *fakeNetBirdCommandRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, netBirdCall{name: name, args: append([]string(nil), args...)}) + if len(f.results) == 0 { + return append([]byte(nil), f.fallback.output...), f.fallback.err + } + result := f.results[0] + f.results = f.results[1:] + return append([]byte(nil), result.output...), result.err +} + +func (f *fakeNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { + _, err := f.Output(ctx, name, args...) + return err +} + +func connectedNetBirdStatus() []byte { + return []byte("Management: Connected\n") +} + +func disconnectedNetBirdStatus() []byte { + return []byte("Management: Disconnected\n") +} + +func newTestNetbird(runner *fakeNetBirdCommandRunner) Netbird { + return Netbird{ + runner: runner, + connectTimeout: 10 * time.Millisecond, + pollInterval: time.Millisecond, + } +} + +func TestNetbirdEnsureConnected_AlreadyConnectedDoesNotReconnect(t *testing.T) { + runner := &fakeNetBirdCommandRunner{results: []netBirdResult{ + {output: []byte("active\n")}, + {output: connectedNetBirdStatus()}, + }} + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err != nil { + t.Fatalf("EnsureConnected() error = %v", err) + } + + wantCalls := []netBirdCall{ + {name: "systemctl", args: []string{"is-active", "netbird"}}, + {name: "netbird", args: []string{"status"}}, + } + if !reflect.DeepEqual(runner.calls, wantCalls) { + t.Fatalf("commands = %#v, want %#v", runner.calls, wantCalls) + } +} + +func TestNetbirdEnsureConnected_StartsInactiveService(t *testing.T) { + runner := &fakeNetBirdCommandRunner{results: []netBirdResult{ + {output: []byte("inactive\n")}, + {}, + {output: connectedNetBirdStatus()}, + }} + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err != nil { + t.Fatalf("EnsureConnected() error = %v", err) + } + + wantCalls := []netBirdCall{ + {name: "systemctl", args: []string{"is-active", "netbird"}}, + {name: "sudo", args: []string{"systemctl", "start", "netbird"}}, + {name: "netbird", args: []string{"status"}}, + } + if !reflect.DeepEqual(runner.calls, wantCalls) { + t.Fatalf("commands = %#v, want %#v", runner.calls, wantCalls) + } +} + +func TestNetbirdEnsureConnected_ReconnectsAndWaitsForConfirmation(t *testing.T) { + runner := &fakeNetBirdCommandRunner{results: []netBirdResult{ + {output: []byte("active\n")}, + {output: disconnectedNetBirdStatus()}, + {}, + {output: disconnectedNetBirdStatus()}, + {output: connectedNetBirdStatus()}, + }} + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err != nil { + t.Fatalf("EnsureConnected() error = %v", err) + } + + wantCalls := []netBirdCall{ + {name: "systemctl", args: []string{"is-active", "netbird"}}, + {name: "netbird", args: []string{"status"}}, + {name: "sudo", args: []string{"netbird", "up"}}, + {name: "netbird", args: []string{"status"}}, + {name: "netbird", args: []string{"status"}}, + } + if !reflect.DeepEqual(runner.calls, wantCalls) { + t.Fatalf("commands = %#v, want %#v", runner.calls, wantCalls) + } +} + +func TestNetbirdEnsureConnected_ReconnectFailure(t *testing.T) { + runner := &fakeNetBirdCommandRunner{results: []netBirdResult{ + {output: []byte("active\n")}, + {output: disconnectedNetBirdStatus()}, + {err: errors.New("up failed")}, + }} + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err == nil || !strings.Contains(err.Error(), "failed to reconnect Brev tunnel") { + t.Fatalf("EnsureConnected() error = %v, want reconnect failure context", err) + } +} + +func TestNetbirdEnsureConnected_StatusNeverConfirmsConnection(t *testing.T) { + runner := &fakeNetBirdCommandRunner{ + results: []netBirdResult{ + {output: []byte("active\n")}, + {output: disconnectedNetBirdStatus()}, + {}, + }, + fallback: netBirdResult{output: disconnectedNetBirdStatus()}, + } + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err == nil || !strings.Contains(err.Error(), "Brev tunnel connection was not confirmed") { + t.Fatalf("EnsureConnected() error = %v, want confirmation timeout", err) + } +} + +func TestNetbirdEnsureConnected_StatusErrorsAreNotSuccess(t *testing.T) { + statusErr := errors.New("netbird status unavailable") + runner := &fakeNetBirdCommandRunner{ + results: []netBirdResult{ + {output: []byte("active\n")}, + {err: statusErr}, + {}, + }, + fallback: netBirdResult{err: statusErr}, + } + + err := newTestNetbird(runner).EnsureConnected(context.Background()) + if err == nil { + t.Fatal("EnsureConnected() error = nil, want confirmation timeout") + } + if !strings.Contains(err.Error(), "Brev tunnel connection was not confirmed") || !strings.Contains(err.Error(), statusErr.Error()) { + t.Fatalf("EnsureConnected() error = %v, want timeout containing latest status failure", err) + } +} diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index 2ad88b43..ba370afb 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -34,14 +34,16 @@ type RegisterStore interface { GetAccessToken() (string, error) } +// NetBirdConnector confirms local NetBird management connectivity. +type NetBirdConnector interface { + EnsureConnected(context.Context) error +} + // NetBirdManager installs, uninstalls, and monitors the NetBird network agent. type NetBirdManager interface { + NetBirdConnector Install() error Uninstall() error - // EnsureRunning checks whether the NetBird service is active and - // connected, starting or reconnecting it if needed. Returns nil when - // the tunnel is healthy. - EnsureRunning() error } // SetupRunner runs a setup script on the local machine. @@ -377,19 +379,17 @@ func checkExistingRegistration(ctx context.Context, t *terminal.Terminal, s Regi ci := node.GetConnectivityInfo() if ci != nil && ci.GetStatus() == nodev1.NetworkMemberStatus_NETWORK_MEMBER_STATUS_CONNECTED { t.Vprint(t.Green(" Node is connected.")) - t.Vprint("") - t.Vprint(" Run 'brev deregister' first if you want to re-register.") - return nil + } else { + t.Vprintf(" Node status: %s\n", externalnode.FriendlyNetworkStatus(ci.GetStatus())) } - t.Vprintf(" Node status: %s\n", externalnode.FriendlyNetworkStatus(ci.GetStatus())) } - // Check local netbird service and start it if down. + // Confirm local NetBird connectivity even when the backend is connected. t.Vprint(" Checking local Brev tunnel...") - if err := deps.netbird.EnsureRunning(); err != nil { + if err := deps.netbird.EnsureConnected(ctx); err != nil { t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: %v", err))) } else { - t.Vprint(t.Green(" Brev tunnel is running.")) + t.Vprint(t.Green(" Brev tunnel is connected.")) } t.Vprint("") @@ -397,18 +397,6 @@ func checkExistingRegistration(ctx context.Context, t *terminal.Terminal, s Regi return nil } -// netbirdManagementConnected parses "netbird status" output and returns true -// when the Management line reports "Connected". -func netbirdManagementConnected(statusOutput string) bool { - for _, line := range strings.Split(statusOutput, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Management:") { - return strings.TrimSpace(strings.TrimPrefix(line, "Management:")) == "Connected" - } - } - return false -} - func runSetup(node *nodev1.ExternalNode, t *terminal.Terminal, deps registerDeps) { ci := node.GetConnectivityInfo() if ci == nil || ci.GetRegistrationCommand() == "" { diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index d98b1a92..7c1b949f 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -117,9 +117,21 @@ func (m mockSelector) Select(_ string, items []string) string { type mockNetBirdManager struct{ err error } -func (m mockNetBirdManager) Install() error { return m.err } -func (m mockNetBirdManager) Uninstall() error { return m.err } -func (m mockNetBirdManager) EnsureRunning() error { return m.err } +func (m mockNetBirdManager) Install() error { return m.err } +func (m mockNetBirdManager) Uninstall() error { return m.err } +func (m mockNetBirdManager) EnsureConnected(context.Context) error { return m.err } + +type reconcilingNetBirdManager struct { + called bool + err error +} + +func (m *reconcilingNetBirdManager) Install() error { return m.err } +func (m *reconcilingNetBirdManager) Uninstall() error { return m.err } +func (m *reconcilingNetBirdManager) EnsureConnected(context.Context) error { + m.called = true + return m.err +} type mockSetupRunner struct { called bool @@ -397,6 +409,38 @@ func Test_runRegister_AlreadyRegistered(t *testing.T) { } } +func TestCheckExistingRegistration_ReconcilesLocalTunnel(t *testing.T) { + regStore := &mockRegistrationStore{ + reg: &DeviceRegistration{ + ExternalNodeID: "unode_existing", + DisplayName: "Existing", + OrgID: "org_123", + }, + } + store := &mockRegisterStore{token: "tok"} + svc := &fakeNodeService{getNodeFn: func(req *nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ + ExternalNode: &nodev1.ExternalNode{ + ExternalNodeId: req.GetExternalNodeId(), + ConnectivityInfo: &nodev1.ConnectivityInfo{ + Status: nodev1.NetworkMemberStatus_NETWORK_MEMBER_STATUS_CONNECTED, + }, + }, + }, nil + }} + deps, server := testRegisterDeps(t, svc, regStore) + defer server.Close() + tunnel := &reconcilingNetBirdManager{} + deps.netbird = tunnel + + if err := checkExistingRegistration(context.Background(), terminal.New(), store, deps); err != nil { + t.Fatalf("checkExistingRegistration() error = %v", err) + } + if !tunnel.called { + t.Fatal("checkExistingRegistration() did not reconcile the local Brev tunnel") + } +} + func Test_runRegister_NoOrganization(t *testing.T) { regStore := &mockRegistrationStore{} From e246665190e57c5735b4dc0f20e95c2360b0bede Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:22:07 -0700 Subject: [PATCH 05/23] fix: preserve Brev tunnel timeout cause --- pkg/cmd/register/providers.go | 2 +- pkg/cmd/register/providers_test.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index 38020d65..d2bcb6be 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -139,7 +139,7 @@ func (n Netbird) EnsureConnected(ctx context.Context) error { if lastStatusErr != nil { return fmt.Errorf("Brev tunnel connection was not confirmed: %w", lastStatusErr) } - return fmt.Errorf("Brev tunnel connection was not confirmed") + return fmt.Errorf("Brev tunnel connection was not confirmed: %w", confirmationCtx.Err()) case <-ticker.C: if checkStatus() { return nil diff --git a/pkg/cmd/register/providers_test.go b/pkg/cmd/register/providers_test.go index c1ef9ff6..a5f92a47 100644 --- a/pkg/cmd/register/providers_test.go +++ b/pkg/cmd/register/providers_test.go @@ -151,6 +151,9 @@ func TestNetbirdEnsureConnected_StatusNeverConfirmsConnection(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "Brev tunnel connection was not confirmed") { t.Fatalf("EnsureConnected() error = %v, want confirmation timeout", err) } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("EnsureConnected() error = %v, want context deadline exceeded", err) + } } func TestNetbirdEnsureConnected_StatusErrorsAreNotSuccess(t *testing.T) { From 4fb420ddeff9aa3f9ad305d83fbd5401a7bf9def Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:35:31 -0700 Subject: [PATCH 06/23] feat: separate network join from SSH enablement --- pkg/cmd/cmd.go | 2 +- pkg/cmd/cmd_test.go | 10 + pkg/cmd/register/device_registration_store.go | 2 +- .../device_registration_store_test.go | 6 +- pkg/cmd/register/providers.go | 4 + pkg/cmd/register/register.go | 212 ++----- pkg/cmd/register/register_test.go | 587 +++++++----------- 7 files changed, 329 insertions(+), 494 deletions(-) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 49b5254d..8f98509f 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -316,7 +316,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(reset.NewCmdReset(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(profile.NewCmdProfile(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(refresh.NewCmdRefresh(t, loginCmdStore)) - cmd.AddCommand(register.NewCmdRegister(t, externalNodeCmdStore)) + cmd.AddCommand(register.NewCmdJoin(t, externalNodeCmdStore)) cmd.AddCommand(deregister.NewCmdDeregister(t, externalNodeCmdStore)) cmd.AddCommand(upgrade.NewCmdUpgrade(t, noLoginCmdStore)) cmd.AddCommand(enablessh.NewCmdEnableSSH(t, externalNodeCmdStore)) diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go index c9289a4c..dbee8d2b 100644 --- a/pkg/cmd/cmd_test.go +++ b/pkg/cmd/cmd_test.go @@ -31,6 +31,16 @@ func newTestFileStore(t *testing.T) *store.FileStore { ) } +func TestNewBrevCommand_BYONCommandSurface(t *testing.T) { + root := NewBrevCommand() + join, _, err := root.Find([]string{"join"}) + require.NoError(t, err) + register, _, err := root.Find([]string{"register"}) + require.NoError(t, err) + require.Equal(t, "join", join.Name()) + require.Same(t, join, register) +} + func TestEmailCachingAuthStore_SaveCachesEmail(t *testing.T) { fs := newTestFileStore(t) s := &emailCachingAuthStore{ diff --git a/pkg/cmd/register/device_registration_store.go b/pkg/cmd/register/device_registration_store.go index 315dfb99..13344314 100644 --- a/pkg/cmd/register/device_registration_store.go +++ b/pkg/cmd/register/device_registration_store.go @@ -80,7 +80,7 @@ func (s *FileRegistrationStore) Load() (*DeviceRegistration, error) { if err != nil { return nil, breverrors.WrapAndTrace(err) } - return nil, breverrors.New("device registration not found, run 'brev register' first") + return nil, breverrors.New("device registration not found, run 'brev join' first") } var reg DeviceRegistration if err := files.ReadJSON(files.AppFs, path, ®); err != nil { diff --git a/pkg/cmd/register/device_registration_store_test.go b/pkg/cmd/register/device_registration_store_test.go index 39d7b1a2..2fb399e7 100644 --- a/pkg/cmd/register/device_registration_store_test.go +++ b/pkg/cmd/register/device_registration_store_test.go @@ -1,6 +1,7 @@ package register import ( + "strings" "testing" "github.com/brevdev/brev-cli/pkg/files" @@ -140,7 +141,10 @@ func Test_LoadRegistration_FailsWhenMissing(t *testing.T) { _, err := store.Load() if err == nil { - t.Error("expected error loading missing registration") + t.Fatal("expected error loading missing registration") + } + if !strings.Contains(err.Error(), "brev join") || strings.Contains(err.Error(), "brev register") { + t.Errorf("expected join recovery guidance, got: %v", err) } } diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index d2bcb6be..6e9a1e35 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -38,6 +38,10 @@ func (TerminalPrompter) Select(label string, items []string) string { }) } +func (TerminalPrompter) Input(content terminal.PromptContent) string { + return terminal.PromptGetInput(content) +} + const ( defaultNetBirdConnectTimeout = 30 * time.Second defaultNetBirdPollInterval = 500 * time.Millisecond diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index ba370afb..c8bfce19 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -1,11 +1,10 @@ -// Package register provides the brev register command for device registration +// Package register provides the brev join command and device registration storage. package register import ( "context" "errors" "fmt" - "os/user" "strings" "time" @@ -51,12 +50,17 @@ type SetupRunner interface { RunSetup(script string) error } -// registerDeps bundles the side-effecting dependencies of runRegister so they +type joinPrompter interface { + terminal.Confirmer + terminal.Selector + Input(terminal.PromptContent) string +} + +// joinDeps bundles the side-effecting dependencies of runJoin so they // can be replaced in tests. -type registerDeps struct { +type joinDeps struct { platform externalnode.PlatformChecker - prompter terminal.Confirmer - selector terminal.Selector + prompter joinPrompter gater sudo.Gater netbird NetBirdManager setupRunner SetupRunner @@ -65,12 +69,11 @@ type registerDeps struct { registrationStore RegistrationStore } -func defaultRegisterDeps() registerDeps { +func defaultJoinDeps() joinDeps { p := TerminalPrompter{} - return registerDeps{ + return joinDeps{ platform: LinuxPlatform{}, prompter: p, - selector: p, gater: sudo.Default, netbird: Netbird{}, setupRunner: ShellSetupRunner{}, @@ -81,23 +84,22 @@ func defaultRegisterDeps() registerDeps { } var ( - registerLong = `Register your device with NVIDIA Brev + joinLong = `Join this device to a Brev network -This command sets up network connectivity and registers this machine with Brev. +This command sets up network connectivity and joins this machine to Brev. Two modes are supported: - • Interactive (default): run 'brev register' with no flags and follow prompts for device name, org, and options. - • Non-interactive: use any of --name, --org, or --ssh-port. No prompts; --name and --org are required. Use for scripts/CI.` + • Interactive (default): run 'brev join' with no flags and follow prompts for device name and organization. + • Non-interactive: use --name and --org. No prompts; both are required. Use for scripts/CI.` - registerExample = ` # Interactive (prompts for device name, org, confirmations) - brev register + joinExample = ` # Interactive (prompts for device name, organization, and confirmations) + brev join # Non-interactive (any flag implies no prompts; --name and --org required) - brev register --name my-node --org my-org - brev register --name my-node --org my-org --ssh-port 22` + brev join --name my-node --org my-org` ) -func NewCmdRegister(t *terminal.Terminal, store RegisterStore) *cobra.Command { +func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command { var orgFlag string var nameFlag string var sshPort int @@ -105,50 +107,56 @@ func NewCmdRegister(t *terminal.Terminal, store RegisterStore) *cobra.Command { cmd := &cobra.Command{ Annotations: map[string]string{"configuration": ""}, - Use: "register", + Use: "join", + Aliases: []string{"register"}, DisableFlagsInUseLine: true, - Short: "Register this device with Brev", - Long: registerLong, - Example: registerExample, + Short: "Join this device to a Brev network", + Long: joinLong, + Example: joinExample, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - interactive := nameFlag == "" && orgFlag == "" && sshPort == 0 - opts := registerOpts{ - interactive: interactive, + if cmd.CalledAs() == "register" { + fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev register" is deprecated; use "brev join" instead.`) + fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer enables SSH; run "brev enable-ssh" separately.`) + } + if cmd.Flags().Changed("ssh-port") { + return fmt.Errorf("--ssh-port is no longer supported by brev join or brev register; run brev join, then run brev enable-ssh on the joined machine") + } + opts := joinOpts{ + interactive: nameFlag == "" && orgFlag == "", name: nameFlag, orgName: orgFlag, - sshPort: int32(sshPort), skipConfirm: approveFlag, } - return runRegister(cmd.Context(), t, store, opts, defaultRegisterDeps()) + return runJoin(cmd.Context(), t, store, opts, defaultJoinDeps()) }, } cmd.Flags().StringVarP(&orgFlag, "org", "o", "", "organization name (required when using non-interactive mode)") cmd.Flags().StringVarP(&nameFlag, "name", "n", "", "device name (required when using non-interactive mode)") - cmd.Flags().IntVarP(&sshPort, "ssh-port", "p", 0, "SSH port (if ssh access is desired)") + cmd.Flags().IntVarP(&sshPort, "ssh-port", "p", 0, "deprecated") + _ = cmd.Flags().MarkHidden("ssh-port") cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip all confirmation prompts (assume yes)") return cmd } -// registerOpts carries mode and inputs: when interactive, name/orgName/sshPort are from prompts; otherwise from flags. -type registerOpts struct { +// joinOpts carries mode and inputs: when interactive, name and orgName are prompted; otherwise they come from flags. +type joinOpts struct { interactive bool name string orgName string - sshPort int32 skipConfirm bool } -// runRegister runs a single registration flow; the only difference by mode is whether we prompt or use opts. -func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, opts registerOpts, deps registerDeps) error { //nolint:gocognit,gocyclo,funlen // ok +// runJoin runs a single membership setup flow; the only difference by mode is whether we prompt or use opts. +func runJoin(ctx context.Context, t *terminal.Terminal, s RegisterStore, opts joinOpts, deps joinDeps) error { //nolint:gocognit,gocyclo,funlen // ok // Basic validation if !deps.platform.IsCompatible() { - return breverrors.New("brev register is only supported on Linux") + return breverrors.New("brev join is only supported on Linux") } // Always gate on sudo; skip confirmation prompt when non-interactive or --approve. - if err := deps.gater.Gate(t, deps.prompter, "Device registration", !opts.interactive || opts.skipConfirm); err != nil { + if err := deps.gater.Gate(t, deps.prompter, "Device join", !opts.interactive || opts.skipConfirm); err != nil { return fmt.Errorf("sudo issue: %w", err) } if !opts.interactive { @@ -158,7 +166,7 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, opt } // Run through the login flow - brevUser, err := s.GetCurrentUser() + _, err := s.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) } @@ -176,7 +184,7 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, opt var name string if opts.interactive { t.Vprint("") - name = terminal.PromptGetInput(terminal.PromptContent{ + name = deps.prompter.Input(terminal.PromptContent{ Label: "Device name", ErrorMsg: "name is required", AllowEmpty: false, @@ -203,7 +211,7 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, opt t.Vprint("") t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint(t.White(" Registering your device with Brev")) + t.Vprint(t.White(" Joining your device to Brev")) t.Vprint(t.White("══════════════════════════════════════════════════")) t.Vprint("") if opts.interactive && !opts.skipConfirm { @@ -216,60 +224,34 @@ func runRegister(ctx context.Context, t *terminal.Terminal, s RegisterStore, opt t.Vprint(t.Yellow(" This will:")) t.Vprint(" 1. Download and install Brev tunnel") t.Vprint(" 2. Collect hardware profile") - t.Vprint(" 3. Register this machine with Brev") - t.Vprint(" 4. Store registration data") + t.Vprint(" 3. Join this machine to Brev") + t.Vprint(" 4. Store join data") t.Vprint(" 5. Connect device to Brev") t.Vprint("") if opts.interactive { - if !opts.skipConfirm && !deps.prompter.ConfirmYesNo("Proceed with registration?") { - t.Vprint("Registration canceled.") + if !opts.skipConfirm && !deps.prompter.ConfirmYesNo("Proceed with join?") { + t.Vprint("Join canceled.") return nil } } - // Perform the registration steps - reg, err := runRegisterSteps(ctx, t, s, name, org, deps) - if err != nil { + if err := runJoinSteps(ctx, t, s, name, org, deps); err != nil { return err } - - // Determine if SSH access should be enabled - enableSSH := false - sshPortForGrant := int32(0) - if opts.interactive { - enableSSH = deps.prompter.ConfirmYesNo("Would you like to enable SSH access to this device?") - if enableSSH { - sshPortForGrant = 0 // prompt for port - } - } else if opts.sshPort != 0 { - enableSSH = true - sshPortForGrant = opts.sshPort - } - - // Grant SSH access if requested - if enableSSH { - osUser, err := user.Current() - if err != nil { - return fmt.Errorf("failed to determine current Linux user: %w", err) - } - if err := grantSSHAccessWithPort(ctx, t, deps, s, reg, brevUser, osUser, sshPortForGrant, opts.interactive, opts.skipConfirm); err != nil { - t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: %v", err))) - } - } - + t.Vprint("") + t.Vprint("SSH access was not enabled. To enable it for your user, run: brev enable-ssh") return nil } -// runRegisterSteps performs netbird install, hardware profile, AddNode, save registration, and runSetup. -// It does not prompt or enable SSH. Used by both flag-driven and prompt-driven flows. -func runRegisterSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore, name string, org *entity.Organization, deps registerDeps) (*DeviceRegistration, error) { +// runJoinSteps performs netbird install, hardware profile, AddNode, save registration, and runSetup. +func runJoinSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore, name string, org *entity.Organization, deps joinDeps) error { t.Vprint("") t.Vprint(t.Yellow("[Step 1/5] Downloading and installing Brev tunnel...")) err := deps.netbird.Install() if err != nil { - return nil, fmt.Errorf("brev tunnel setup failed: %w", err) + return fmt.Errorf("brev tunnel setup failed: %w", err) } t.Vprintf("%s Brev tunnel ready.\n", t.Green(" ✓")) @@ -277,7 +259,7 @@ func runRegisterSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore t.Vprint(t.Yellow("[Step 2/5] Collecting hardware profile...")) hwProfile, err := deps.hardwareProfiler.Profile() if err != nil { - return nil, fmt.Errorf("failed to collect hardware profile: %w", err) + return fmt.Errorf("failed to collect hardware profile: %w", err) } t.Vprintf("%s Hardware profile collected.\n", t.Green(" ✓")) t.Vprint("") @@ -285,7 +267,7 @@ func runRegisterSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore t.Vprint(FormatHardwareProfile(hwProfile)) t.Vprint("") - t.Vprint(t.Yellow("[Step 3/5] Registering device with Brev...")) + t.Vprint(t.Yellow("[Step 3/5] Joining device to Brev...")) deviceID := uuid.New().String() client := deps.nodeClients.NewNodeClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) addResp, err := client.AddNode(ctx, connect.NewRequest(&nodev1.AddNodeRequest{ @@ -299,9 +281,9 @@ func runRegisterSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore // its message directly, which already reads as "node already exists". var connectErr *connect.Error if errors.As(err, &connectErr) && connectErr.Code() == connect.CodeAlreadyExists { - return nil, errors.New(connectErr.Message()) + return errors.New(connectErr.Message()) } - return nil, fmt.Errorf("failed to register node: %w", err) + return fmt.Errorf("failed to join node: %w", err) } node := addResp.Msg.GetExternalNode() @@ -318,24 +300,24 @@ func runRegisterSteps(ctx context.Context, t *terminal.Terminal, s RegisterStore t.Vprint("") t.Vprint(t.Yellow("[Step 4/5] Storing registration data...")) if err := deps.registrationStore.Save(reg); err != nil { - return nil, fmt.Errorf("node registered but failed to save locally: %w", err) + return fmt.Errorf("node joined but failed to save locally: %w", err) } t.Vprint("") t.Vprint(t.Yellow("[Step 5/5] Connecting device to Brev...")) runSetup(node, t, deps) - t.Vprintf("%s Node registered.\n", t.Green(" ✓")) - t.Vprintf("%s Registration complete.\n", t.Green(" ✓")) - return reg, nil + t.Vprintf("%s Node joined.\n", t.Green(" ✓")) + t.Vprintf("%s Join complete.\n", t.Green(" ✓")) + return nil } -func resolveOrgInteractive(t *terminal.Terminal, s RegisterStore, deps registerDeps) (*entity.Organization, error) { +func resolveOrgInteractive(t *terminal.Terminal, s RegisterStore, deps joinDeps) (*entity.Organization, error) { list, err := s.ListOrganizations() if err != nil { return nil, breverrors.WrapAndTrace(err) } - org, err := helpers.SelectOrganizationInteractive(t, list, deps.selector) + org, err := helpers.SelectOrganizationInteractive(t, list, deps.prompter) if err != nil { return nil, breverrors.WrapAndTrace(err) } @@ -354,7 +336,7 @@ func resolveOrg(s RegisterStore, orgName string) (*entity.Organization, error) { // It calls GetNode to check the server-side NetworkMemberStatus and ensures the // local netbird service is running, starting it if necessary. Returns nil if // the node is healthy, or an error describing what's wrong. -func checkExistingRegistration(ctx context.Context, t *terminal.Terminal, s RegisterStore, deps registerDeps) error { +func checkExistingRegistration(ctx context.Context, t *terminal.Terminal, s RegisterStore, deps joinDeps) error { reg, loadErr := deps.registrationStore.Load() if loadErr != nil { return fmt.Errorf("this machine is already registered but the registration file could not be read: %w", loadErr) @@ -393,11 +375,11 @@ func checkExistingRegistration(ctx context.Context, t *terminal.Terminal, s Regi } t.Vprint("") - t.Vprint(" Run 'brev deregister' first if you want to re-register.") + t.Vprint(" Run 'brev leave' first if you want to rejoin.") return nil } -func runSetup(node *nodev1.ExternalNode, t *terminal.Terminal, deps registerDeps) { +func runSetup(node *nodev1.ExternalNode, t *terminal.Terminal, deps joinDeps) { ci := node.GetConnectivityInfo() if ci == nil || ci.GetRegistrationCommand() == "" { t.Vprintf(" %s\n", t.Yellow("Warning: Brev tunnel setup failed, please try again.")) @@ -411,59 +393,3 @@ func runSetup(node *nodev1.ExternalNode, t *terminal.Terminal, deps registerDeps } } } - -// grantSSHAccessWithPort enables SSH: shows confirm table, uses port or prompts if port is 0, then allocates port and grants access. -func grantSSHAccessWithPort(ctx context.Context, t *terminal.Terminal, deps registerDeps, tokenProvider externalnode.TokenProvider, reg *DeviceRegistration, brevUser *entity.User, osUser *user.User, port int32, interactive bool, skipConfirm bool) error { - brevUserName := brevUser.Username - if brevUserName == "" { - brevUserName = brevUser.Email - } - if brevUserName == "" { - brevUserName = brevUser.ID - } - - t.Vprint("") - t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint(t.White(" Enabling SSH access on this device")) - t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint("") - if interactive && !skipConfirm { - t.Vprint(t.Green(" Please confirm before continuing:")) - t.Vprint("") - } - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Device:")), t.BoldBlue(reg.DisplayName+" ("+reg.ExternalNodeID+")")) - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Organization:")), t.BoldBlue(reg.OrgName+" ("+reg.OrgID+")")) - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Brev user:")), t.BoldBlue(brevUserName+" ("+brevUser.ID+")")) - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Linux user:")), t.BoldBlue(osUser.Username)) - - var err error - if port == 0 { - t.Vprint("") - port, err = PromptSSHPort(t) - if err != nil { - return fmt.Errorf("invalid SSH port: %w", err) - } - } else { - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "SSH port:")), t.BoldBlue(fmt.Sprintf("%d", port))) - } - t.Vprint("") - - return grantSSHAccess(ctx, t, deps, tokenProvider, reg, brevUser, osUser, port) -} - -func grantSSHAccess(ctx context.Context, t *terminal.Terminal, deps registerDeps, tokenProvider externalnode.TokenProvider, reg *DeviceRegistration, brevUser *entity.User, osUser *user.User, port int32) error { - brevPortID, err := OpenSSHPort(ctx, t, deps.nodeClients, tokenProvider, reg, port) - if err != nil { - return fmt.Errorf("allocate SSH port failed: %w", err) - } - - err = SetupAndRegisterNodeSSHAccess(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, osUser.Username, brevPortID) - if err != nil { - return fmt.Errorf("grant SSH failed: %w", err) - } - - t.Vprint("") - t.Vprint(t.Green(fmt.Sprintf("SSH access enabled. You can now SSH to this device via: brev shell %s", reg.DisplayName))) - t.Vprint("") - return nil -} diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 7c1b949f..d9c50f48 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -1,9 +1,12 @@ package register import ( + "bytes" "context" "fmt" + "io" "net/http/httptest" + "os" "strings" "testing" @@ -15,8 +18,172 @@ import ( "github.com/brevdev/brev-cli/pkg/externalnode" "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" ) +const legacySSHPortMigrationError = "--ssh-port is no longer supported by brev join or brev register; run brev join, then run brev enable-ssh on the joined machine" + +type panicRegisterStore struct{} + +func (panicRegisterStore) GetCurrentUser() (*entity.User, error) { panic("GetCurrentUser called") } +func (panicRegisterStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) { + panic("GetActiveOrganizationOrDefault called") +} +func (panicRegisterStore) GetOrganizationsByName(string) ([]entity.Organization, error) { + panic("GetOrganizationsByName called") +} +func (panicRegisterStore) ListOrganizations() ([]entity.Organization, error) { + panic("ListOrganizations called") +} +func (panicRegisterStore) GetAccessToken() (string, error) { panic("GetAccessToken called") } + +func TestNewCmdJoin_CommandSurface(t *testing.T) { + cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) + root := &cobra.Command{Use: "brev"} + root.AddCommand(cmd) + + resolved, _, err := root.Find([]string{"register"}) + require.NoError(t, err) + require.Equal(t, "join", cmd.Name()) + require.Equal(t, []string{"register"}, cmd.Aliases) + require.Same(t, cmd, resolved) + require.Error(t, cmd.Args(cmd, []string{"unexpected"})) + require.True(t, cmd.Flags().Lookup("ssh-port").Hidden) +} + +func TestNewCmdJoin_RegisterAliasWarnsOnExecution(t *testing.T) { + cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) + root := &cobra.Command{Use: "brev", SilenceUsage: true} + root.AddCommand(cmd) + var stderr bytes.Buffer + root.SetErr(&stderr) + root.SetArgs([]string{"register", "--ssh-port", "22"}) + + err := root.Execute() + + require.EqualError(t, err, legacySSHPortMigrationError) + require.Contains(t, stderr.String(), "Warning: \"brev register\" is deprecated; use \"brev join\" instead.\nThis command no longer enables SSH; run \"brev enable-ssh\" separately.\n") +} + +func TestNewCmdJoin_HelpDoesNotWarn(t *testing.T) { + cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) + root := &cobra.Command{Use: "brev", SilenceUsage: true} + root.AddCommand(cmd) + var stderr bytes.Buffer + root.SetErr(&stderr) + root.SetArgs([]string{"register", "--help"}) + + require.NoError(t, root.Execute()) + require.Empty(t, stderr.String()) +} + +func TestNewCmdJoin_LegacySSHPortFailsBeforeSideEffects(t *testing.T) { + tests := [][]string{ + {"join", "--ssh-port", "22"}, + {"join", "--ssh-port", "0"}, + {"join", "-p", "22"}, + {"register", "--ssh-port", "22"}, + {"register", "-p", "22"}, + } + + for _, args := range tests { + t.Run(strings.Join(args, " "), func(t *testing.T) { + cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) + root := &cobra.Command{Use: "brev", SilenceUsage: true} + root.AddCommand(cmd) + root.SetErr(&bytes.Buffer{}) + root.SetArgs(args) + + require.EqualError(t, root.Execute(), legacySSHPortMigrationError) + }) + } +} + +type recordingJoinPrompter struct { + inputs []terminal.PromptContent +} + +func (p *recordingJoinPrompter) ConfirmYesNo(string) bool { return true } +func (p *recordingJoinPrompter) Select(_ string, items []string) string { + return items[0] +} +func (p *recordingJoinPrompter) Input(content terminal.PromptContent) string { + p.inputs = append(p.inputs, content) + return "interactive-node" +} + +func TestRunJoin_InteractivePromptsOnlyForMembership(t *testing.T) { + regStore := &mockRegistrationStore{} + store := &mockRegisterStore{ + user: &entity.User{ID: "user_1"}, + org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, + token: "tok", + } + svc := &fakeNodeService{addNodeFn: func(req *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { + return &nodev1.AddNodeResponse{ExternalNode: &nodev1.ExternalNode{ + ExternalNodeId: "unode_abc", OrganizationId: req.GetOrganizationId(), Name: req.GetName(), DeviceId: req.GetDeviceId(), + }}, nil + }} + deps, server := testJoinDeps(t, svc, regStore) + defer server.Close() + prompter := &recordingJoinPrompter{} + deps.prompter = prompter + + require.NoError(t, runJoin(context.Background(), terminal.New(), store, joinOpts{interactive: true}, deps)) + require.Len(t, prompter.inputs, 1) + require.Equal(t, "Device name", prompter.inputs[0].Label) +} + +func TestRunJoin_DoesNotOpenPortOrGrantSSH(t *testing.T) { + regStore := &mockRegistrationStore{} + store := &mockRegisterStore{ + user: &entity.User{ID: "user_1"}, + org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, + token: "tok", + } + openCalls, grantCalls := 0, 0 + svc := &fakeNodeService{ + addNodeFn: func(req *nodev1.AddNodeRequest) (*nodev1.AddNodeResponse, error) { + return &nodev1.AddNodeResponse{ExternalNode: &nodev1.ExternalNode{ + ExternalNodeId: "unode_abc", OrganizationId: req.GetOrganizationId(), Name: req.GetName(), DeviceId: req.GetDeviceId(), + }}, nil + }, + openPortFn: func(*nodev1.OpenPortRequest) (*nodev1.OpenPortResponse, error) { + openCalls++ + return &nodev1.OpenPortResponse{}, nil + }, + grantNodeSSHAccessFn: func(*nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) { + grantCalls++ + return &nodev1.GrantNodeSSHAccessResponse{}, nil + }, + } + deps, server := testJoinDeps(t, svc, regStore) + defer server.Close() + + stdout := captureStdout(t) + require.NoError(t, runJoin(context.Background(), terminal.New(), store, joinOpts{name: "my-node", orgName: "TestOrg"}, deps)) + require.Equal(t, 0, openCalls) + require.Equal(t, 0, grantCalls) + require.Contains(t, stdout(), "brev enable-ssh") +} + +func captureStdout(t *testing.T) func() string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + require.NoError(t, err) + os.Stdout = writer + return func() string { + require.NoError(t, writer.Close()) + os.Stdout = previous + output, err := io.ReadAll(reader) + require.NoError(t, err) + require.NoError(t, reader.Close()) + return string(output) + } +} + // mockRegisterStore satisfies RegisterStore for orchestration tests. type mockRegisterStore struct { user *entity.User @@ -88,7 +255,7 @@ func (m *mockRegistrationStore) Exists() (bool, error) { return m.reg != nil, nil } -// mock types for registerDeps interfaces +// mock types for joinDeps interfaces type mockPlatform struct{ compatible bool } @@ -96,23 +263,13 @@ func (m mockPlatform) IsCompatible() bool { return m.compatible } type mockConfirmer struct{ confirm bool } -func (m mockConfirmer) ConfirmYesNo(_ string) bool { return m.confirm } - -// mockSelector implements terminal.Selector by returning the first item (for tests that need org selection). -type mockSelector struct{ choice string } - -func (m mockSelector) Select(_ string, items []string) string { - if m.choice != "" { - for _, s := range items { - if s == m.choice { - return s - } - } +func (m mockConfirmer) ConfirmYesNo(_ string) bool { return m.confirm } +func (m mockConfirmer) Input(_ terminal.PromptContent) string { return "" } +func (m mockConfirmer) Select(_ string, items []string) string { + if len(items) == 0 { + return "" } - if len(items) > 0 { - return items[0] - } - return "" + return items[0] } type mockNetBirdManager struct{ err error } @@ -173,18 +330,17 @@ func testHardwareProfile() *HardwareProfile { } } -// testRegisterDeps returns deps with all side effects stubbed out, and a fake +// testJoinDeps returns deps with all side effects stubbed out, and a fake // ConnectRPC server backed by the provided fakeNodeService. -func testRegisterDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationStore) (registerDeps, *httptest.Server) { +func testJoinDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationStore) (joinDeps, *httptest.Server) { t.Helper() _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) server := httptest.NewServer(handler) - return registerDeps{ + return joinDeps{ platform: mockPlatform{compatible: true}, prompter: mockConfirmer{confirm: true}, - selector: mockSelector{}, gater: sudo.CachedGater{}, netbird: mockNetBirdManager{}, setupRunner: &mockSetupRunner{}, @@ -196,7 +352,7 @@ func testRegisterDeps(t *testing.T, svc *fakeNodeService, regStore RegistrationS }, server } -func Test_runRegister_HappyPath(t *testing.T) { +func Test_runJoin_HappyPath(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -230,19 +386,16 @@ func Test_runRegister_HappyPath(t *testing.T) { setupRunner := &mockSetupRunner{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.setupRunner = setupRunner - SetTestSSHPort(22) - defer ClearTestSSHPort() - term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err != nil { - t.Fatalf("runRegister failed: %v", err) + t.Fatalf("runJoin failed: %v", err) } // Verify registration was persisted @@ -251,7 +404,7 @@ func Test_runRegister_HappyPath(t *testing.T) { t.Fatalf("Exists error: %v", err) } if !exists { - t.Fatal("expected registration to exist after successful register") + t.Fatal("expected registration to exist after successful join") } reg, err := regStore.Load() @@ -274,14 +427,14 @@ func Test_runRegister_HappyPath(t *testing.T) { } } -// gaterFromFunc adapts a function to sudo.Gater; used only by Test_runRegister_UserCancels. +// gaterFromFunc adapts a function to sudo.Gater; used only by Test_runJoin_UserCancels. type gaterFromFunc func(*terminal.Terminal, terminal.Confirmer, string, bool) error func (f gaterFromFunc) Gate(t *terminal.Terminal, c terminal.Confirmer, reason string, assumeYes bool) error { return f(t, c, reason, assumeYes) } -func Test_runRegister_UserCancels(t *testing.T) { +func Test_runJoin_UserCancels(t *testing.T) { // User cancel happens in interactive mode (sudo or confirm). Flag-driven has no prompts. regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -290,7 +443,7 @@ func Test_runRegister_UserCancels(t *testing.T) { token: "tok", } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.prompter = mockConfirmer{confirm: false} @@ -307,8 +460,8 @@ func Test_runRegister_UserCancels(t *testing.T) { }) term := terminal.New() - opts := registerOpts{interactive: true, name: "", orgName: "", sshPort: 0} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: true, name: "", orgName: ""} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when user declines sudo gate") } @@ -322,7 +475,7 @@ func Test_runRegister_UserCancels(t *testing.T) { } } -func Test_runRegister_AlreadyRegistered(t *testing.T) { +func Test_runJoin_AlreadyRegistered(t *testing.T) { tests := []struct { name string getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) @@ -389,14 +542,14 @@ func Test_runRegister_AlreadyRegistered(t *testing.T) { } svc := &fakeNodeService{getNodeFn: tt.getNodeFn} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() // Pass the same name as the existing registration so we go through // the checkExistingRegistration path (not the different-name path). - opts := registerOpts{interactive: false, name: "Existing", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "Existing", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err != nil { t.Fatalf("expected nil error, got: %v", err) } @@ -428,7 +581,7 @@ func TestCheckExistingRegistration_ReconcilesLocalTunnel(t *testing.T) { }, }, nil }} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() tunnel := &reconcilingNetBirdManager{} deps.netbird = tunnel @@ -441,7 +594,7 @@ func TestCheckExistingRegistration_ReconcilesLocalTunnel(t *testing.T) { } } -func Test_runRegister_NoOrganization(t *testing.T) { +func Test_runJoin_NoOrganization(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -452,18 +605,18 @@ func Test_runRegister_NoOrganization(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when no org exists") } } -func Test_runRegister_WithOrgFlag(t *testing.T) { +func Test_runJoin_WithOrgFlag(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -491,18 +644,15 @@ func Test_runRegister_WithOrgFlag(t *testing.T) { } setupRunner := &mockSetupRunner{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.setupRunner = setupRunner - SetTestSSHPort(22) - defer ClearTestSSHPort() - term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "SpecificOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "SpecificOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err != nil { - t.Fatalf("runRegister with --org failed: %v", err) + t.Fatalf("runJoin with --org failed: %v", err) } if capturedOrgID != "org_456" { @@ -518,7 +668,7 @@ func Test_runRegister_WithOrgFlag(t *testing.T) { } } -func Test_runRegister_WithOrgFlag_NotFound(t *testing.T) { +func Test_runJoin_WithOrgFlag_NotFound(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -529,12 +679,12 @@ func Test_runRegister_WithOrgFlag_NotFound(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "NonexistentOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "NonexistentOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when org not found") } @@ -543,7 +693,7 @@ func Test_runRegister_WithOrgFlag_NotFound(t *testing.T) { } } -func Test_runRegister_AddNodeFails(t *testing.T) { +func Test_runJoin_AddNodeFails(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -559,12 +709,12 @@ func Test_runRegister_AddNodeFails(t *testing.T) { }, } - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when AddNode fails") } @@ -579,7 +729,7 @@ func Test_runRegister_AddNodeFails(t *testing.T) { } } -func Test_runRegister_NoSetupCommand(t *testing.T) { +func Test_runJoin_NoSetupCommand(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -605,19 +755,16 @@ func Test_runRegister_NoSetupCommand(t *testing.T) { setupRunner := &mockSetupRunner{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.setupRunner = setupRunner - SetTestSSHPort(22) - defer ClearTestSSHPort() - term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err != nil { - t.Fatalf("runRegister failed: %v", err) + t.Fatalf("runJoin failed: %v", err) } if setupRunner.called { @@ -706,110 +853,7 @@ Peers count: 0/0 Connected` } } -func Test_runRegister_GrantSSH_retries_on_connection_error_then_succeeds(t *testing.T) { - regStore := &mockRegistrationStore{} - - store := &mockRegisterStore{ - user: &entity.User{ID: "user_1"}, - org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, - token: "tok", - } - - var grantCalls int - 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 - }, - grantNodeSSHAccessFn: func(_ *nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) { - grantCalls++ - if grantCalls < 2 { - return nil, connect.NewError(connect.CodeInternal, nil) - } - return &nodev1.GrantNodeSSHAccessResponse{}, nil - }, - } - - deps, server := testRegisterDeps(t, svc, regStore) - defer server.Close() - - deps.prompter = mockConfirmer{confirm: true} - - SetTestSSHPort(22) - defer ClearTestSSHPort() - - term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) - if err != nil { - t.Fatalf("runRegister failed: %v", err) - } - - if grantCalls != 2 { - t.Errorf("expected GrantNodeSSHAccess to be called 2 times (retry once), got %d", grantCalls) - } -} - -func Test_runRegister_GrantSSH_no_retry_on_permanent_error(t *testing.T) { - regStore := &mockRegistrationStore{} - - store := &mockRegisterStore{ - user: &entity.User{ID: "user_1"}, - org: &entity.Organization{ID: "org_123", Name: "TestOrg"}, - token: "tok", - } - - var grantCalls int - 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 - }, - grantNodeSSHAccessFn: func(_ *nodev1.GrantNodeSSHAccessRequest) (*nodev1.GrantNodeSSHAccessResponse, error) { - grantCalls++ - return nil, connect.NewError(connect.CodePermissionDenied, nil) - }, - } - - deps, server := testRegisterDeps(t, svc, regStore) - defer server.Close() - - deps.prompter = mockConfirmer{confirm: true} - - SetTestSSHPort(22) - defer ClearTestSSHPort() - - term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) - if err != nil { - t.Fatalf("runRegister should not fail the overall flow when SSH grant fails: %v", err) - } - - if grantCalls != 1 { - t.Errorf("expected GrantNodeSSHAccess to be called once (no retry on permanent error), got %d", grantCalls) - } -} - -func Test_runRegister_NameValidation(t *testing.T) { +func Test_runJoin_NameValidation(t *testing.T) { tests := []struct { name string input string @@ -852,16 +896,13 @@ func Test_runRegister_NameValidation(t *testing.T) { }, } - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() - SetTestSSHPort(22) - defer ClearTestSSHPort() - term := terminal.New() var err error - opts := registerOpts{interactive: false, name: tt.input, orgName: "TestOrg", sshPort: 22} - err = runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: tt.input, orgName: "TestOrg"} + err = runJoin(context.Background(), term, store, opts, deps) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -876,7 +917,7 @@ func Test_runRegister_NameValidation(t *testing.T) { } } -func Test_runRegister_PlatformIncompatible(t *testing.T) { +func Test_runJoin_PlatformIncompatible(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -886,14 +927,14 @@ func Test_runRegister_PlatformIncompatible(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.platform = mockPlatform{compatible: false} term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when platform is incompatible") } @@ -902,7 +943,7 @@ func Test_runRegister_PlatformIncompatible(t *testing.T) { } } -func Test_runRegister_HardwareProfilerFailure(t *testing.T) { +func Test_runJoin_HardwareProfilerFailure(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -912,14 +953,14 @@ func Test_runRegister_HardwareProfilerFailure(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.hardwareProfiler = &mockHardwareProfiler{err: fmt.Errorf("nvml init failed")} term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when hardware profiler fails") } @@ -928,7 +969,7 @@ func Test_runRegister_HardwareProfilerFailure(t *testing.T) { } } -func Test_runRegister_NetBirdInstallFailure(t *testing.T) { +func Test_runJoin_NetBirdInstallFailure(t *testing.T) { regStore := &mockRegistrationStore{} store := &mockRegisterStore{ @@ -938,14 +979,14 @@ func Test_runRegister_NetBirdInstallFailure(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() deps.netbird = mockNetBirdManager{err: fmt.Errorf("install failed")} term := terminal.New() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "my-spark", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when NetBird install fails") } @@ -954,7 +995,7 @@ func Test_runRegister_NetBirdInstallFailure(t *testing.T) { } } -func Test_runRegister_NoNameNotRegistered(t *testing.T) { +func Test_runJoin_NoNameNotRegistered(t *testing.T) { // In flag-driven mode, missing --name and --org must error (no prompts). regStore := &mockRegistrationStore{} @@ -965,12 +1006,12 @@ func Test_runRegister_NoNameNotRegistered(t *testing.T) { } svc := &fakeNodeService{} - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() - opts := registerOpts{interactive: false, name: "", orgName: "", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "", orgName: ""} + err := runJoin(context.Background(), term, store, opts, deps) if err == nil { t.Fatal("expected error when no name/org in non-interactive mode") } @@ -979,7 +1020,7 @@ func Test_runRegister_NoNameNotRegistered(t *testing.T) { } } -func Test_runRegister_NoNameAlreadyRegistered(t *testing.T) { +func Test_runJoin_NoNameAlreadyRegistered(t *testing.T) { regStore := &mockRegistrationStore{ reg: &DeviceRegistration{ ExternalNodeID: "unode_existing", @@ -1007,12 +1048,12 @@ func Test_runRegister_NoNameAlreadyRegistered(t *testing.T) { }, } - deps, server := testRegisterDeps(t, svc, regStore) + deps, server := testJoinDeps(t, svc, regStore) defer server.Close() term := terminal.New() - opts := registerOpts{interactive: false, name: "Existing", orgName: "TestOrg", sshPort: 22} - err := runRegister(context.Background(), term, store, opts, deps) + opts := joinOpts{interactive: false, name: "Existing", orgName: "TestOrg"} + err := runJoin(context.Background(), term, store, opts, deps) if err != nil { t.Fatalf("expected nil error when already registered with no name, got: %v", err) } @@ -1023,153 +1064,3 @@ func Test_runRegister_NoNameAlreadyRegistered(t *testing.T) { t.Error("expected registration to still exist") } } - -func Test_runRegister_OpenSSHPort(t *testing.T) { // nolint:funlen, gocyclo, gocognit // 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_TCP { - t.Errorf("expected PORT_PROTOCOL_TCP, 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: "InvalidPortNoAPICall", - port: 99999, - verify: func(t *testing.T, openReq *nodev1.OpenPortRequest, _ *nodev1.GrantNodeSSHAccessRequest, regStore *mockRegistrationStore, err error) { - t.Helper() - if err != nil { - t.Fatalf("registration should succeed even when SSH port is invalid (soft error), got: %v", err) - } - if openReq != nil { - t.Error("expected OpenPort NOT to be called for invalid port") - } - exists, _ := regStore.Exists() - if !exists { - t.Error("expected registration to still exist after invalid port") - } - }, - }, - { - 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() - opts := registerOpts{interactive: false, name: "my-spark", orgName: "TestOrg", sshPort: tt.port} - err := runRegister(context.Background(), term, store, opts, deps) - - tt.verify(t, gotOpenReq, gotGrantReq, regStore, err) - }) - } -} From 06a7c16713c7a8b17d3b0717f8cc5f9303952e00 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:42:23 -0700 Subject: [PATCH 07/23] test: strengthen join compatibility coverage --- pkg/cmd/register/register.go | 6 ++++- pkg/cmd/register/register_test.go | 38 ++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index c8bfce19..665bd9ce 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -100,6 +100,10 @@ Two modes are supported: ) func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command { + return newCmdJoin(t, store, defaultJoinDeps) +} + +func newCmdJoin(t *terminal.Terminal, store RegisterStore, depsFactory func() joinDeps) *cobra.Command { var orgFlag string var nameFlag string var sshPort int @@ -128,7 +132,7 @@ func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command { orgName: orgFlag, skipConfirm: approveFlag, } - return runJoin(cmd.Context(), t, store, opts, defaultJoinDeps()) + return runJoin(cmd.Context(), t, store, opts, depsFactory()) }, } diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index d9c50f48..6664bfca 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -80,36 +80,55 @@ func TestNewCmdJoin_HelpDoesNotWarn(t *testing.T) { func TestNewCmdJoin_LegacySSHPortFailsBeforeSideEffects(t *testing.T) { tests := [][]string{ - {"join", "--ssh-port", "22"}, {"join", "--ssh-port", "0"}, + {"join", "--ssh-port", "22"}, + {"join", "-p", "0"}, {"join", "-p", "22"}, + {"register", "--ssh-port", "0"}, {"register", "--ssh-port", "22"}, + {"register", "-p", "0"}, {"register", "-p", "22"}, } for _, args := range tests { t.Run(strings.Join(args, " "), func(t *testing.T) { - cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) + depsConstructed := 0 + cmd := newCmdJoin(terminal.New(), panicRegisterStore{}, func() joinDeps { + depsConstructed++ + return joinDeps{} + }) root := &cobra.Command{Use: "brev", SilenceUsage: true} root.AddCommand(cmd) root.SetErr(&bytes.Buffer{}) root.SetArgs(args) require.EqualError(t, root.Execute(), legacySSHPortMigrationError) + // All platform, sudo, authentication, NetBird, RPC, persistence, + // setup, and hardware work is contained in the dependency factory. + require.Zero(t, depsConstructed) }) } } type recordingJoinPrompter struct { - inputs []terminal.PromptContent + prompts []joinPrompt } -func (p *recordingJoinPrompter) ConfirmYesNo(string) bool { return true } -func (p *recordingJoinPrompter) Select(_ string, items []string) string { +type joinPrompt struct { + kind string + label string +} + +func (p *recordingJoinPrompter) ConfirmYesNo(label string) bool { + p.prompts = append(p.prompts, joinPrompt{kind: "confirm", label: label}) + return true +} +func (p *recordingJoinPrompter) Select(label string, items []string) string { + p.prompts = append(p.prompts, joinPrompt{kind: "select", label: label}) return items[0] } func (p *recordingJoinPrompter) Input(content terminal.PromptContent) string { - p.inputs = append(p.inputs, content) + p.prompts = append(p.prompts, joinPrompt{kind: "input", label: content.Label}) return "interactive-node" } @@ -131,8 +150,11 @@ func TestRunJoin_InteractivePromptsOnlyForMembership(t *testing.T) { deps.prompter = prompter require.NoError(t, runJoin(context.Background(), terminal.New(), store, joinOpts{interactive: true}, deps)) - require.Len(t, prompter.inputs, 1) - require.Equal(t, "Device name", prompter.inputs[0].Label) + require.Equal(t, []joinPrompt{ + {kind: "input", label: "Device name"}, + {kind: "select", label: "Select organization"}, + {kind: "confirm", label: "Proceed with join?"}, + }, prompter.prompts) } func TestRunJoin_DoesNotOpenPortOrGrantSSH(t *testing.T) { From 1e903ed3c710c292046bc1d53f7093a2acc4ac3c Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:50:55 -0700 Subject: [PATCH 08/23] feat: require joined tunnel before enabling SSH --- pkg/cmd/enablessh/enablessh.go | 97 ++++++----- pkg/cmd/enablessh/enablessh_test.go | 246 +++++++++++++++++++++++++--- pkg/cmd/register/node.go | 37 +++++ pkg/cmd/register/node_test.go | 98 +++++++++++ 4 files changed, 420 insertions(+), 58 deletions(-) create mode 100644 pkg/cmd/register/node.go create mode 100644 pkg/cmd/register/node_test.go diff --git a/pkg/cmd/enablessh/enablessh.go b/pkg/cmd/enablessh/enablessh.go index 9788b0e6..f239407a 100644 --- a/pkg/cmd/enablessh/enablessh.go +++ b/pkg/cmd/enablessh/enablessh.go @@ -9,10 +9,8 @@ import ( "os/user" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" - "connectrpc.com/connect" "github.com/brevdev/brev-cli/pkg/cmd/register" - "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/externalnode" @@ -27,21 +25,44 @@ type EnableSSHStore interface { GetAccessToken() (string, error) } +type sshAccessProvisioner interface { + Provision( + context.Context, + *terminal.Terminal, + externalnode.TokenProvider, + *register.DeviceRegistration, + *entity.User, + *nodev1.ExternalNode, + ) error +} + // enableSSHDeps bundles the side-effecting dependencies of runEnableSSH so they // can be replaced in tests. type enableSSHDeps struct { platform externalnode.PlatformChecker nodeClients externalnode.NodeClientFactory registrationStore register.RegistrationStore - prompter terminal.Selector + tunnel register.NetBirdConnector + provisioner sshAccessProvisioner +} + +type defaultSSHAccessProvisioner struct { + prompter terminal.Selector + nodeClients externalnode.NodeClientFactory } func defaultEnableSSHDeps() enableSSHDeps { + prompter := register.TerminalPrompter{} + nodeClients := register.DefaultNodeClientFactory{} return enableSSHDeps{ platform: register.LinuxPlatform{}, - nodeClients: register.DefaultNodeClientFactory{}, + nodeClients: nodeClients, registrationStore: register.NewFileRegistrationStore(), - prompter: register.TerminalPrompter{}, + tunnel: register.Netbird{}, + provisioner: defaultSSHAccessProvisioner{ + prompter: prompter, + nodeClients: nodeClients, + }, } } @@ -50,9 +71,10 @@ func NewCmdEnableSSH(t *terminal.Terminal, store EnableSSHStore) *cobra.Command Annotations: map[string]string{"configuration": ""}, Use: "enable-ssh", DisableFlagsInUseLine: true, - Short: "Enable SSH access to this registered device", - Long: "Enable SSH access to this registered device for the current Brev user.", + Short: "Enable SSH access to this joined node", + Long: "Enable SSH access to this joined node for the current Brev user.", Example: " brev enable-ssh", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runEnableSSH(cmd.Context(), t, store, defaultEnableSSHDeps()) }, @@ -66,9 +88,17 @@ func runEnableSSH(ctx context.Context, t *terminal.Terminal, s EnableSSHStore, d return fmt.Errorf("brev enable-ssh is only supported on Linux") } + exists, err := deps.registrationStore.Exists() + if err != nil { + return fmt.Errorf("check joined-device registration: %w", err) + } + if !exists { + return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) + } + reg, err := deps.registrationStore.Load() if err != nil { - return fmt.Errorf("failed to read registration file: %w", err) + return fmt.Errorf("read joined-device registration: %w", err) } brevUser, err := s.GetCurrentUser() @@ -76,18 +106,30 @@ func runEnableSSH(ctx context.Context, t *terminal.Terminal, s EnableSSHStore, d return breverrors.WrapAndTrace(err) } - return enableSSH(ctx, t, deps, s, reg, brevUser) + node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, s, reg) + if err != nil { + return fmt.Errorf("enable SSH failed: %w", err) + } + if err := deps.tunnel.EnsureConnected(ctx); err != nil { + return fmt.Errorf("enable SSH requires a connected Brev tunnel: %w", err) + } + if err := deps.provisioner.Provision(ctx, t, s, reg, brevUser, node); err != nil { + return fmt.Errorf("enable SSH failed: %w", err) + } + + t.Vprint(t.Green(fmt.Sprintf("SSH access enabled. You can now SSH to this device via: brev shell %s", reg.DisplayName))) + return nil } -// enableSSH grants SSH access to the given node for the current Brev user. +// Provision grants SSH access to the joined node for the current Brev user. // This is the "reflexive grant" — granting yourself SSH access to the device. -func enableSSH( +func (p defaultSSHAccessProvisioner) Provision( ctx context.Context, t *terminal.Terminal, - deps enableSSHDeps, tokenProvider externalnode.TokenProvider, reg *register.DeviceRegistration, brevUser *entity.User, + node *nodev1.ExternalNode, ) error { linuxUser, err := user.Current() if err != nil { @@ -105,41 +147,18 @@ func enableSSH( t.Vprintf(" Linux user: %s\n", linuxUsername) t.Vprint("") - node, err := fetchRegisteredNode(ctx, deps, tokenProvider, reg) - if err != nil { - return fmt.Errorf("enable SSH failed: %w", err) - } - - brevPortID, err := register.ResolveSSHAccessPort(ctx, t, deps.prompter, deps.nodeClients, tokenProvider, reg, node) + brevPortID, err := register.ResolveSSHAccessPort(ctx, t, p.prompter, p.nodeClients, tokenProvider, reg, node) if err != nil { - return fmt.Errorf("enable SSH failed: %w", err) + return err } - if err := register.SetupAndRegisterNodeSSHAccess(ctx, t, deps.nodeClients, tokenProvider, reg, brevUser, linuxUsername, brevPortID); err != nil { - return fmt.Errorf("enable SSH failed: %w", err) + if err := register.SetupAndRegisterNodeSSHAccess(ctx, t, p.nodeClients, tokenProvider, reg, brevUser, linuxUsername, brevPortID); err != nil { + return err } - t.Vprint(t.Green(fmt.Sprintf("SSH access enabled. You can now SSH to this device via: brev shell %s", reg.DisplayName))) return nil } -func fetchRegisteredNode( - ctx context.Context, - deps enableSSHDeps, - tokenProvider externalnode.TokenProvider, - reg *register.DeviceRegistration, -) (*nodev1.ExternalNode, error) { - client := deps.nodeClients.NewNodeClient(tokenProvider, config.GlobalConfig.GetBrevPublicAPIURL()) - resp, err := client.GetNode(ctx, connect.NewRequest(&nodev1.GetNodeRequest{ - ExternalNodeId: reg.ExternalNodeID, - OrganizationId: reg.OrgID, - })) - if err != nil { - return nil, fmt.Errorf("error retrieving node: %w", err) - } - return resp.Msg.GetExternalNode(), 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) { diff --git a/pkg/cmd/enablessh/enablessh_test.go b/pkg/cmd/enablessh/enablessh_test.go index 7df94144..aae633d4 100644 --- a/pkg/cmd/enablessh/enablessh_test.go +++ b/pkg/cmd/enablessh/enablessh_test.go @@ -2,19 +2,24 @@ package enablessh import ( "context" + "errors" "net/http/httptest" "os" "os/user" "path/filepath" + "slices" "strings" "testing" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" + "github.com/stretchr/testify/require" "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/terminal" ) // tempUser returns a *user.User whose HomeDir points to a temporary directory. @@ -226,18 +231,25 @@ func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider type mockEnableSSHStore struct { token string + user *entity.User + err error } -func (m *mockEnableSSHStore) GetCurrentUser() (interface{}, error) { return nil, nil } -func (m *mockEnableSSHStore) GetAccessToken() (string, error) { return m.token, nil } +func (m *mockEnableSSHStore) GetCurrentUser() (*entity.User, error) { return m.user, m.err } +func (m *mockEnableSSHStore) GetAccessToken() (string, error) { return m.token, nil } // fakeNodeService implements the server side of ExternalNodeService for testing. type fakeNodeService struct { nodev1connect.UnimplementedExternalNodeServiceHandler - getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) + getNodeFn func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) + order *[]string + addNodeCalls int } func (f *fakeNodeService) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { + if f.order != nil { + *f.order = append(*f.order, "node") + } resp, err := f.getNodeFn(req.Msg) if err != nil { return nil, err @@ -245,6 +257,11 @@ func (f *fakeNodeService) GetNode(_ context.Context, req *connect.Request[nodev1 return connect.NewResponse(resp), nil } +func (f *fakeNodeService) AddNode(_ context.Context, _ *connect.Request[nodev1.AddNodeRequest]) (*connect.Response[nodev1.AddNodeResponse], error) { + f.addNodeCalls++ + return connect.NewResponse(&nodev1.AddNodeResponse{}), nil +} + func startFakeServer(t *testing.T, svc *fakeNodeService) (enableSSHDeps, *httptest.Server) { t.Helper() _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) @@ -255,27 +272,218 @@ func startFakeServer(t *testing.T, svc *fakeNodeService) (enableSSHDeps, *httpte }, server } -func Test_fetchRegisteredNode(t *testing.T) { +type enableSSHOrder struct{ entries []string } + +func (o *enableSSHOrder) add(entry string) { o.entries = append(o.entries, entry) } + +type orderedPlatform struct{ order *enableSSHOrder } + +func (p orderedPlatform) IsCompatible() bool { + p.order.add("platform") + return true +} + +type orderedRegistrationStore struct { + order *enableSSHOrder + reg *register.DeviceRegistration + exists bool + err error +} + +func (s *orderedRegistrationStore) Save(*register.DeviceRegistration) error { + return errors.New("Save must not be called") +} +func (s *orderedRegistrationStore) Load() (*register.DeviceRegistration, error) { + return s.reg, s.err +} +func (s *orderedRegistrationStore) Delete() error { return errors.New("Delete must not be called") } +func (s *orderedRegistrationStore) Exists() (bool, error) { + s.order.add("registration") + return s.exists, s.err +} + +type orderedEnableSSHStore struct { + order *enableSSHOrder + user *entity.User +} + +func (s orderedEnableSSHStore) GetCurrentUser() (*entity.User, error) { + s.order.add("auth") + return s.user, nil +} +func (orderedEnableSSHStore) GetAccessToken() (string, error) { return "token", nil } + +type orderedTunnel struct { + order *enableSSHOrder + err error +} + +func (t orderedTunnel) EnsureConnected(context.Context) error { + t.order.add("tunnel") + return t.err +} + +type orderedProvisioner struct { + order *enableSSHOrder + err error +} + +func (p orderedProvisioner) Provision( + context.Context, + *terminal.Terminal, + externalnode.TokenProvider, + *register.DeviceRegistration, + *entity.User, + *nodev1.ExternalNode, +) error { + p.order.add("provision") + return p.err +} + +func newEnableSSHTestDeps(order *enableSSHOrder, factory externalnode.NodeClientFactory, registrationStore register.RegistrationStore, tunnelErr error) enableSSHDeps { + return enableSSHDeps{ + platform: orderedPlatform{order: order}, + nodeClients: factory, + registrationStore: registrationStore, + tunnel: orderedTunnel{order: order, err: tunnelErr}, + provisioner: orderedProvisioner{order: order}, + } +} + +func TestNewCmdEnableSSH_RejectsPositionalArguments(t *testing.T) { + cmd := NewCmdEnableSSH(terminal.New(), &mockEnableSSHStore{}) + require.Error(t, cmd.Args(cmd, []string{"unexpected"})) +} + +func TestRunEnableSSH_MissingRegistrationDirectsUserToJoin(t *testing.T) { + order := &enableSSHOrder{} + registrationStore := &orderedRegistrationStore{order: order, exists: false} + deps := newEnableSSHTestDeps(order, nil, registrationStore, nil) + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order}, deps) + + require.EqualError(t, err, `This machine has not joined a Brev network; run "brev join" first.`) + require.Equal(t, []string{"platform", "registration"}, order.entries) +} + +func TestRunEnableSSH_MissingBackendNodeDoesNotConnectOrProvision(t *testing.T) { + order := &enableSSHOrder{} svc := &fakeNodeService{ - getNodeFn: func(req *nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { - if req.GetExternalNodeId() != "unode_abc" { - t.Fatalf("unexpected node id %q", req.GetExternalNodeId()) - } - return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ - ExternalNodeId: "unode_abc", - Ports: []*nodev1.Port{{PortId: "port_1", PortNumber: 11640, ServerPort: 22}}, - }}, nil + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{}, nil }, } deps, _ := startFakeServer(t, svc) - store := &mockEnableSSHStore{token: "tok"} - reg := ®ister.DeviceRegistration{ExternalNodeID: "unode_abc", OrgID: "org_1"} + registrationStore := &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.platform = orderedPlatform{order: order} + deps.registrationStore = registrationStore + deps.tunnel = orderedTunnel{order: order} + deps.provisioner = orderedProvisioner{order: order} - node, err := fetchRegisteredNode(context.Background(), deps, store, reg) - if err != nil { - t.Fatal(err) + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.ErrorContains(t, err, "registered node was not returned by Brev") + require.Equal(t, []string{"platform", "registration", "auth", "node"}, order.entries) +} + +func TestRunEnableSSH_ConnectedTunnelProvisionsSSH(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, + } + deps, _ := startFakeServer(t, svc) + registrationStore := &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456", DisplayName: "joined-node"}} + deps.platform = orderedPlatform{order: order} + deps.registrationStore = registrationStore + deps.tunnel = orderedTunnel{order: order} + deps.provisioner = orderedProvisioner{order: order} + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.NoError(t, err) + require.Equal(t, []string{"platform", "registration", "auth", "node", "tunnel", "provision"}, order.entries) +} + +func TestRunEnableSSH_ReconnectsBeforeProvisioning(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, } - if len(node.GetPorts()) != 1 || node.GetPorts()[0].GetPortId() != "port_1" { - t.Fatalf("unexpected node: %+v", node) + deps, _ := startFakeServer(t, svc) + deps.platform = orderedPlatform{order: order} + deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.tunnel = orderedTunnel{order: order} + deps.provisioner = orderedProvisioner{order: order} + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.NoError(t, err) + require.Less(t, slices.Index(order.entries, "tunnel"), slices.Index(order.entries, "provision")) +} + +func TestRunEnableSSH_TunnelFailureDoesNotProvision(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, } + deps, _ := startFakeServer(t, svc) + deps.platform = orderedPlatform{order: order} + deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.tunnel = orderedTunnel{order: order, err: errors.New("tunnel failed")} + deps.provisioner = orderedProvisioner{order: order} + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.ErrorContains(t, err, "enable SSH requires a connected Brev tunnel") + require.NotContains(t, order.entries, "provision") +} + +func TestRunEnableSSH_UnconfirmedTunnelDoesNotProvision(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, + } + deps, _ := startFakeServer(t, svc) + deps.platform = orderedPlatform{order: order} + deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.tunnel = orderedTunnel{order: order, err: errors.New("Brev tunnel connection was not confirmed")} + deps.provisioner = orderedProvisioner{order: order} + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.ErrorContains(t, err, "Brev tunnel connection was not confirmed") + require.NotContains(t, order.entries, "provision") +} + +func TestRunEnableSSH_NeverAddsNode(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, + } + deps, _ := startFakeServer(t, svc) + deps.platform = orderedPlatform{order: order} + deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.tunnel = orderedTunnel{order: order} + deps.provisioner = orderedProvisioner{order: order} + + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + + require.NoError(t, err) + require.Zero(t, svc.addNodeCalls) } diff --git a/pkg/cmd/register/node.go b/pkg/cmd/register/node.go new file mode 100644 index 00000000..df524186 --- /dev/null +++ b/pkg/cmd/register/node.go @@ -0,0 +1,37 @@ +package register + +import ( + "context" + "fmt" + + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + + "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/externalnode" +) + +// FetchRegisteredNode retrieves the backend node represented by a local joined-device registration. +func FetchRegisteredNode( + ctx context.Context, + nodeClients externalnode.NodeClientFactory, + tokenProvider externalnode.TokenProvider, + reg *DeviceRegistration, +) (*nodev1.ExternalNode, error) { + client := nodeClients.NewNodeClient(tokenProvider, config.GlobalConfig.GetBrevPublicAPIURL()) + resp, err := client.GetNode(ctx, connect.NewRequest(&nodev1.GetNodeRequest{ + ExternalNodeId: reg.ExternalNodeID, + OrganizationId: reg.OrgID, + })) + if err != nil { + return nil, fmt.Errorf("error retrieving joined node: %w", err) + } + return registeredNodeFromResponse(resp) +} + +func registeredNodeFromResponse(resp *connect.Response[nodev1.GetNodeResponse]) (*nodev1.ExternalNode, error) { + if resp == nil || resp.Msg == nil || resp.Msg.GetExternalNode() == nil { + return nil, fmt.Errorf(`registered node was not returned by Brev; run "brev leave" and "brev join" to repair membership`) + } + return resp.Msg.GetExternalNode(), nil +} diff --git a/pkg/cmd/register/node_test.go b/pkg/cmd/register/node_test.go new file mode 100644 index 00000000..1dabf234 --- /dev/null +++ b/pkg/cmd/register/node_test.go @@ -0,0 +1,98 @@ +package register + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/externalnode" +) + +type registeredNodeTestFactory struct{ serverURL string } + +func (f registeredNodeTestFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { + return NewNodeServiceClient(provider, f.serverURL) +} + +type registeredNodeTestTokenProvider struct{} + +func (registeredNodeTestTokenProvider) GetAccessToken() (string, error) { return "token", nil } + +type registeredNodeTestService struct { + nodev1connect.UnimplementedExternalNodeServiceHandler + getNode func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) +} + +func (s registeredNodeTestService) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { + resp, err := s.getNode(req.Msg) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} + +func startRegisteredNodeTestServer(t *testing.T, service registeredNodeTestService) registeredNodeTestFactory { + t.Helper() + _, handler := nodev1connect.NewExternalNodeServiceHandler(service) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return registeredNodeTestFactory{serverURL: server.URL} +} + +func TestFetchRegisteredNode_Success(t *testing.T) { + factory := startRegisteredNodeTestServer(t, registeredNodeTestService{ + getNode: func(req *nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + require.Equal(t, "unode_123", req.GetExternalNodeId()) + require.Equal(t, "org_456", req.GetOrganizationId()) + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, + }) + + node, err := FetchRegisteredNode(context.Background(), factory, registeredNodeTestTokenProvider{}, &DeviceRegistration{ + ExternalNodeID: "unode_123", + OrgID: "org_456", + }) + + require.NoError(t, err) + require.Equal(t, "unode_123", node.GetExternalNodeId()) +} + +func TestFetchRegisteredNode_RPCError(t *testing.T) { + factory := startRegisteredNodeTestServer(t, registeredNodeTestService{ + getNode: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return nil, connect.NewError(connect.CodeInternal, errors.New("backend unavailable")) + }, + }) + + _, err := FetchRegisteredNode(context.Background(), factory, registeredNodeTestTokenProvider{}, &DeviceRegistration{ + ExternalNodeID: "unode_123", + OrgID: "org_456", + }) + + require.Error(t, err) + require.ErrorContains(t, err, "error retrieving joined node") +} + +func TestFetchRegisteredNode_NilNodeIsError(t *testing.T) { + tests := []struct { + name string + resp *connect.Response[nodev1.GetNodeResponse] + }{ + {name: "nil response"}, + {name: "nil message", resp: &connect.Response[nodev1.GetNodeResponse]{}}, + {name: "nil node", resp: connect.NewResponse(&nodev1.GetNodeResponse{})}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := registeredNodeFromResponse(tt.resp) + require.EqualError(t, err, `registered node was not returned by Brev; run "brev leave" and "brev join" to repair membership`) + }) + } +} From 9225b49bc20f606167dffb3efbd194d509129885 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 12:55:53 -0700 Subject: [PATCH 09/23] test: prove enable-ssh reconnects before provisioning --- pkg/cmd/enablessh/enablessh_test.go | 49 ++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/enablessh/enablessh_test.go b/pkg/cmd/enablessh/enablessh_test.go index aae633d4..6b14bca0 100644 --- a/pkg/cmd/enablessh/enablessh_test.go +++ b/pkg/cmd/enablessh/enablessh_test.go @@ -7,7 +7,6 @@ import ( "os" "os/user" "path/filepath" - "slices" "strings" "testing" @@ -323,6 +322,21 @@ func (t orderedTunnel) EnsureConnected(context.Context) error { return t.err } +type reconnectingTunnel struct { + order *enableSSHOrder + connected *bool + reconnectAttempts int +} + +func (t *reconnectingTunnel) EnsureConnected(context.Context) error { + t.order.add("tunnel") + if !*t.connected { + t.reconnectAttempts++ + *t.connected = true + } + return nil +} + type orderedProvisioner struct { order *enableSSHOrder err error @@ -340,6 +354,28 @@ func (p orderedProvisioner) Provision( return p.err } +type connectedTunnelProvisioner struct { + order *enableSSHOrder + tunnelConnected *bool + observedConnected bool +} + +func (p *connectedTunnelProvisioner) Provision( + context.Context, + *terminal.Terminal, + externalnode.TokenProvider, + *register.DeviceRegistration, + *entity.User, + *nodev1.ExternalNode, +) error { + p.order.add("provision") + p.observedConnected = *p.tunnelConnected + if !p.observedConnected { + return errors.New("SSH provisioning started before the Brev tunnel connected") + } + return nil +} + func newEnableSSHTestDeps(order *enableSSHOrder, factory externalnode.NodeClientFactory, registrationStore register.RegistrationStore, tunnelErr error) enableSSHDeps { return enableSSHDeps{ platform: orderedPlatform{order: order}, @@ -410,6 +446,7 @@ func TestRunEnableSSH_ConnectedTunnelProvisionsSSH(t *testing.T) { func TestRunEnableSSH_ReconnectsBeforeProvisioning(t *testing.T) { order := &enableSSHOrder{} + tunnelConnected := false svc := &fakeNodeService{ order: &order.entries, getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { @@ -419,13 +456,17 @@ func TestRunEnableSSH_ReconnectsBeforeProvisioning(t *testing.T) { deps, _ := startFakeServer(t, svc) deps.platform = orderedPlatform{order: order} deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} - deps.tunnel = orderedTunnel{order: order} - deps.provisioner = orderedProvisioner{order: order} + tunnel := &reconnectingTunnel{order: order, connected: &tunnelConnected} + deps.tunnel = tunnel + provisioner := &connectedTunnelProvisioner{order: order, tunnelConnected: &tunnelConnected} + deps.provisioner = provisioner err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) require.NoError(t, err) - require.Less(t, slices.Index(order.entries, "tunnel"), slices.Index(order.entries, "provision")) + require.Equal(t, 1, tunnel.reconnectAttempts) + require.True(t, provisioner.observedConnected) + require.Equal(t, []string{"platform", "registration", "auth", "node", "tunnel", "provision"}, order.entries) } func TestRunEnableSSH_TunnelFailureDoesNotProvision(t *testing.T) { From 863f1216953d6d45155001f7588449a685ec8eb4 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 13:33:47 -0700 Subject: [PATCH 10/23] feat: add privileged node-wide Brev key cleanup --- main.go | 12 + pkg/cmd/disablessh/localkeys.go | 208 +++++++++++++ pkg/cmd/disablessh/localkeys_linux.go | 281 +++++++++++++++++ pkg/cmd/disablessh/localkeys_linux_test.go | 287 ++++++++++++++++++ pkg/cmd/disablessh/localkeys_test.go | 278 +++++++++++++++++ pkg/cmd/disablessh/localkeys_unsupported.go | 16 + pkg/cmd/disablessh/testdata/.gitattributes | 2 + .../disablessh/testdata/authorized_keys.after | 3 + .../testdata/authorized_keys.before | 5 + pkg/cmd/disablessh/testdata/passwd.txt | 5 + pkg/cmd/register/sshkeys.go | 8 +- pkg/cmd/register/sshkeys_test.go | 22 ++ 12 files changed, 1124 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/disablessh/localkeys.go create mode 100644 pkg/cmd/disablessh/localkeys_linux.go create mode 100644 pkg/cmd/disablessh/localkeys_linux_test.go create mode 100644 pkg/cmd/disablessh/localkeys_test.go create mode 100644 pkg/cmd/disablessh/localkeys_unsupported.go create mode 100644 pkg/cmd/disablessh/testdata/.gitattributes create mode 100644 pkg/cmd/disablessh/testdata/authorized_keys.after create mode 100644 pkg/cmd/disablessh/testdata/authorized_keys.before create mode 100644 pkg/cmd/disablessh/testdata/passwd.txt diff --git a/main.go b/main.go index 66b4d97e..b87c2f67 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,27 @@ package main import ( + "context" + "fmt" "os" "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/cmd" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" + "github.com/brevdev/brev-cli/pkg/cmd/disablessh" "github.com/brevdev/brev-cli/pkg/errors" ) func main() { + handled, err := disablessh.RunLocalKeyCleanupHelper(context.Background(), os.Args[1:], os.Stdout) + if handled { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + done := errors.GetDefaultErrorReporter().Setup() defer done() defer analytics.Close() diff --git a/pkg/cmd/disablessh/localkeys.go b/pkg/cmd/disablessh/localkeys.go new file mode 100644 index 00000000..0631f74b --- /dev/null +++ b/pkg/cmd/disablessh/localkeys.go @@ -0,0 +1,208 @@ +package disablessh + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "runtime" + "strings" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +const cleanupHelperArg = "__brev-disable-ssh-cleanup" + +type KeyCleanupResult struct { + AccountsScanned int `json:"accounts_scanned"` + AccountsChanged int `json:"accounts_changed"` + KeysRemoved int `json:"keys_removed"` +} + +type localAccount struct { + Username string + HomeDir string +} + +type localKeyCleaner interface { + RemoveBrevKeys(context.Context) (KeyCleanupResult, error) +} + +func parsePasswd(data []byte) ([]localAccount, error) { + lines := bytes.Split(data, []byte("\n")) + accounts := make([]localAccount, 0, len(lines)) + seenHomes := make(map[string]struct{}, len(lines)) + for i, line := range lines { + line = bytes.TrimSuffix(line, []byte("\r")) + if len(line) == 0 { + continue + } + fields := bytes.Split(line, []byte(":")) + if len(fields) != 7 { + return nil, fmt.Errorf("parse passwd line %d: expected seven fields, got %d", i+1, len(fields)) + } + home := string(fields[5]) + if !path.IsAbs(home) { + return nil, fmt.Errorf("parse passwd line %d: home directory %q is not absolute", i+1, home) + } + if _, ok := seenHomes[home]; ok { + continue + } + seenHomes[home] = struct{}{} + accounts = append(accounts, localAccount{Username: string(fields[0]), HomeDir: home}) + } + return accounts, nil +} + +func stripBrevManagedAuthorizedKeyLines(data []byte) ([]byte, int) { + segments := bytes.SplitAfter(data, []byte("\n")) + cleaned := make([]byte, 0, len(data)) + removed := 0 + for _, segment := range segments { + line := bytes.TrimSuffix(segment, []byte("\n")) + line = bytes.TrimSuffix(line, []byte("\r")) + if register.IsBrevManagedAuthorizedKeysLine(string(line)) { + removed++ + continue + } + cleaned = append(cleaned, segment...) + } + return cleaned, removed +} + +type systemLocalKeyCleaner struct { + listAccounts func(context.Context) ([]localAccount, error) + cleanAccount func(localAccount) (int, error) +} + +func (c systemLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { + accounts, err := c.listAccounts(ctx) + if err != nil { + return KeyCleanupResult{}, fmt.Errorf("enumerate local accounts: %w", err) + } + + var result KeyCleanupResult + var accountErrs []error + for _, account := range accounts { + removed, err := c.cleanAccount(account) + if err != nil { + accountErrs = append(accountErrs, fmt.Errorf("clean Brev keys for account %q at %q: %w", account.Username, account.HomeDir, err)) + continue + } + result.AccountsScanned++ + if removed > 0 { + result.AccountsChanged++ + result.KeysRemoved += removed + } + } + if err := breverrors.Join(accountErrs...); err != nil { + return result, fmt.Errorf("clean one or more local accounts: %w", err) + } + return result, nil +} + +func newSystemLocalKeyCleaner() localKeyCleaner { + return systemLocalKeyCleaner{ + listAccounts: listLocalAccounts, + cleanAccount: cleanLocalAccount, + } +} + +type privilegedCommandRunner interface { + Output(context.Context, string, ...string) ([]byte, error) +} + +type execPrivilegedCommandRunner struct{} + +func (execPrivilegedCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + output, err := exec.CommandContext(ctx, name, args...).Output() + if err == nil { + return output, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return nil, fmt.Errorf("%w (stderr: %s)", err, strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("execute privileged command: %w", err) +} + +type privilegedLocalKeyCleaner struct { + geteuid func() int + executable func() (string, error) + runner privilegedCommandRunner + direct localKeyCleaner +} + +func (c privilegedLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { + if c.geteuid() == 0 { + result, err := c.direct.RemoveBrevKeys(ctx) + if err != nil { + return result, fmt.Errorf("run direct Brev key cleanup: %w", err) + } + return result, nil + } + executable, err := c.executable() + if err != nil { + return KeyCleanupResult{}, fmt.Errorf("locate Brev executable: %w", err) + } + output, err := c.runner.Output(ctx, "sudo", "-n", executable, cleanupHelperArg) + if err != nil { + return KeyCleanupResult{}, fmt.Errorf("run privileged Brev key cleanup: %w", err) + } + var result KeyCleanupResult + if err := json.Unmarshal(output, &result); err != nil { + return KeyCleanupResult{}, fmt.Errorf("decode privileged Brev key cleanup result: %w", err) + } + return result, nil +} + +func newPrivilegedLocalKeyCleaner() localKeyCleaner { //nolint:unused // Used by the Task 5 disable-ssh command. + return privilegedLocalKeyCleaner{ + geteuid: os.Geteuid, + executable: os.Executable, + runner: execPrivilegedCommandRunner{}, + direct: newSystemLocalKeyCleaner(), + } +} + +// RunLocalKeyCleanupHelper runs the fixed privileged key-cleanup mode when +// selected by args. Normal CLI arguments are ignored. +func RunLocalKeyCleanupHelper(ctx context.Context, args []string, stdout io.Writer) (bool, error) { + return runLocalKeyCleanupHelper(ctx, args, stdout, runtime.GOOS, os.Geteuid, newSystemLocalKeyCleaner()) +} + +func runLocalKeyCleanupHelper( + ctx context.Context, + args []string, + stdout io.Writer, + goos string, + geteuid func() int, + cleaner localKeyCleaner, +) (bool, error) { + if len(args) == 0 || args[0] != cleanupHelperArg { + return false, nil + } + if len(args) != 1 { + return true, fmt.Errorf("privileged Brev key cleanup requires exactly one fixed argument") + } + if goos != "linux" { + return true, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") + } + if geteuid() != 0 { + return true, fmt.Errorf("privileged Brev key cleanup must run as root") + } + result, err := cleaner.RemoveBrevKeys(ctx) + if err != nil { + return true, fmt.Errorf("run local Brev key cleanup: %w", err) + } + if err := json.NewEncoder(stdout).Encode(result); err != nil { + return true, fmt.Errorf("encode privileged Brev key cleanup result: %w", err) + } + return true, nil +} diff --git a/pkg/cmd/disablessh/localkeys_linux.go b/pkg/cmd/disablessh/localkeys_linux.go new file mode 100644 index 00000000..417c5520 --- /dev/null +++ b/pkg/cmd/disablessh/localkeys_linux.go @@ -0,0 +1,281 @@ +//go:build linux + +package disablessh + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + authorizedKeysName = "authorized_keys" + tempFilePrefix = "authorized_keys.brev-cleanup-" +) + +type getentCommandRunner interface { + Output(context.Context, string, ...string) ([]byte, error) +} + +type execGetentCommandRunner struct{} + +func (execGetentCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + output, err := exec.CommandContext(ctx, name, args...).Output() + if err == nil { + return output, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return nil, fmt.Errorf("%w (stderr: %s)", err, strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, err +} + +func resolveGetent(exists func(string) bool) (string, error) { + for _, candidate := range [...]string{"/usr/bin/getent", "/bin/getent"} { + if exists(candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("getent not found at /usr/bin/getent or /bin/getent") +} + +func listLocalAccounts(ctx context.Context) ([]localAccount, error) { + getentPath, err := resolveGetent(func(candidate string) bool { + info, err := os.Stat(candidate) + return err == nil && info.Mode().IsRegular() + }) + if err != nil { + return nil, err + } + return listLocalAccountsWith(ctx, getentPath, execGetentCommandRunner{}) +} + +func listLocalAccountsWith(ctx context.Context, getentPath string, runner getentCommandRunner) ([]localAccount, error) { + output, err := runner.Output(ctx, getentPath, "passwd") + if err != nil { + return nil, fmt.Errorf("run getent passwd: %w", err) + } + accounts, err := parsePasswd(output) + if err != nil { + return nil, fmt.Errorf("parse getent passwd output: %w", err) + } + return accounts, nil +} + +func cleanLocalAccount(account localAccount) (int, error) { + homeFD, err := openAbsoluteDirectory(account.HomeDir) + if err != nil { + if errors.Is(err, unix.ENOENT) { + return 0, nil + } + return 0, fmt.Errorf("open home directory %q: %w", account.HomeDir, err) + } + defer closeDescriptor(homeFD) + + sshFD, err := unix.Openat(homeFD, ".ssh", directoryOpenFlags(), 0) + if err != nil { + if errors.Is(err, unix.ENOENT) { + return 0, nil + } + return 0, fmt.Errorf("open .ssh under home %q: %w", account.HomeDir, err) + } + defer closeDescriptor(sshFD) + + var beforeOpen unix.Stat_t + if err := unix.Fstatat(sshFD, authorizedKeysName, &beforeOpen, unix.AT_SYMLINK_NOFOLLOW); err != nil { + if errors.Is(err, unix.ENOENT) { + return 0, nil + } + return 0, fmt.Errorf("inspect authorized_keys under home %q: %w", account.HomeDir, err) + } + if !isRegular(beforeOpen) { + return 0, fmt.Errorf("authorized_keys under home %q is not a regular file", account.HomeDir) + } + + authorizedKeysFD, err := unix.Openat( + sshFD, + authorizedKeysName, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, + 0, + ) + if err != nil { + if errors.Is(err, unix.ENOENT) { + return 0, nil + } + return 0, fmt.Errorf("open authorized_keys under home %q: %w", account.HomeDir, err) + } + authorizedKeysFile := os.NewFile(uintptr(authorizedKeysFD), authorizedKeysName) + if authorizedKeysFile == nil { + closeDescriptor(authorizedKeysFD) + return 0, fmt.Errorf("open authorized_keys under home %q: invalid file descriptor", account.HomeDir) + } + defer func() { _ = authorizedKeysFile.Close() }() + + var opened unix.Stat_t + if err := unix.Fstat(authorizedKeysFD, &opened); err != nil { + return 0, fmt.Errorf("inspect opened authorized_keys under home %q: %w", account.HomeDir, err) + } + if !isRegular(opened) { + return 0, fmt.Errorf("opened authorized_keys under home %q is not a regular file", account.HomeDir) + } + if !sameFileIdentity(beforeOpen, opened) { + return 0, fmt.Errorf("authorized_keys under home %q changed while opening", account.HomeDir) + } + + data, err := io.ReadAll(authorizedKeysFile) + if err != nil { + return 0, fmt.Errorf("read authorized_keys under home %q: %w", account.HomeDir, err) + } + cleaned, removed := stripBrevManagedAuthorizedKeyLines(data) + if removed == 0 { + return 0, nil + } + + if err := replaceAuthorizedKeys(sshFD, cleaned, opened); err != nil { + return 0, fmt.Errorf("replace authorized_keys under home %q: %w", account.HomeDir, err) + } + return removed, nil +} + +func directoryOpenFlags() int { + return unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW +} + +func openAbsoluteDirectory(home string) (int, error) { + if !path.IsAbs(home) { + return -1, fmt.Errorf("path %q is not absolute", home) + } + for _, component := range strings.Split(home, "/") { + if component == ".." { + return -1, fmt.Errorf("path %q contains parent traversal", home) + } + } + + currentFD, err := unix.Open("/", directoryOpenFlags(), 0) + if err != nil { + return -1, fmt.Errorf("open root directory: %w", err) + } + cleaned := path.Clean(home) + for _, component := range strings.Split(strings.TrimPrefix(cleaned, "/"), "/") { + if component == "" || component == "." { + continue + } + nextFD, err := unix.Openat(currentFD, component, directoryOpenFlags(), 0) + if err != nil { + closeDescriptor(currentFD) + return -1, fmt.Errorf("open path component %q: %w", component, err) + } + if err := unix.Close(currentFD); err != nil { + closeDescriptor(nextFD) + return -1, fmt.Errorf("close parent directory before %q: %w", component, err) + } + currentFD = nextFD + } + return currentFD, nil +} + +func isRegular(stat unix.Stat_t) bool { + return stat.Mode&unix.S_IFMT == unix.S_IFREG +} + +func sameFileIdentity(a, b unix.Stat_t) bool { + return a.Dev == b.Dev && a.Ino == b.Ino && a.Mode&unix.S_IFMT == b.Mode&unix.S_IFMT +} + +func replaceAuthorizedKeys(sshFD int, cleaned []byte, original unix.Stat_t) error { + tempFD, tempName, err := createRandomTempFile(sshFD) + if err != nil { + return err + } + renamed := false + defer func() { + if tempFD >= 0 { + closeDescriptor(tempFD) + } + if !renamed { + _ = unix.Unlinkat(sshFD, tempName, 0) + } + }() + + if err := writeAll(tempFD, cleaned); err != nil { + return fmt.Errorf("write temporary authorized_keys: %w", err) + } + if err := unix.Fchown(tempFD, int(original.Uid), int(original.Gid)); err != nil { + return fmt.Errorf("restore temporary authorized_keys ownership: %w", err) + } + if err := unix.Fchmod(tempFD, original.Mode&0o7777); err != nil { + return fmt.Errorf("restore temporary authorized_keys mode: %w", err) + } + if err := unix.Fsync(tempFD); err != nil { + return fmt.Errorf("sync temporary authorized_keys: %w", err) + } + if err := unix.Close(tempFD); err != nil { + tempFD = -1 + return fmt.Errorf("close temporary authorized_keys: %w", err) + } + tempFD = -1 + if err := unix.Renameat(sshFD, tempName, sshFD, authorizedKeysName); err != nil { + return fmt.Errorf("rename temporary authorized_keys: %w", err) + } + renamed = true + if err := unix.Fsync(sshFD); err != nil { + return fmt.Errorf("sync .ssh directory: %w", err) + } + return nil +} + +func createRandomTempFile(sshFD int) (int, string, error) { + for range 128 { + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return -1, "", fmt.Errorf("generate temporary authorized_keys name: %w", err) + } + name := tempFilePrefix + hex.EncodeToString(random) + fd, err := unix.Openat( + sshFD, + name, + unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0o600, + ) + if err == nil { + return fd, name, nil + } + if !errors.Is(err, unix.EEXIST) { + return -1, "", fmt.Errorf("create temporary authorized_keys: %w", err) + } + } + return -1, "", fmt.Errorf("create temporary authorized_keys: exhausted random names") +} + +func writeAll(fd int, data []byte) error { + for len(data) > 0 { + n, err := unix.Write(fd, data) + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +func closeDescriptor(fd int) { + if fd >= 0 { + _ = unix.Close(fd) + } +} diff --git a/pkg/cmd/disablessh/localkeys_linux_test.go b/pkg/cmd/disablessh/localkeys_linux_test.go new file mode 100644 index 00000000..2f9c34cd --- /dev/null +++ b/pkg/cmd/disablessh/localkeys_linux_test.go @@ -0,0 +1,287 @@ +//go:build linux + +package disablessh + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +type fakeGetentRunner struct { + output []byte + err error + name string + args []string +} + +func (f *fakeGetentRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.name = name + f.args = append([]string(nil), args...) + return append([]byte(nil), f.output...), f.err +} + +func TestResolveGetent_UsesOnlyFixedCandidates(t *testing.T) { + var checked []string + got, err := resolveGetent(func(candidate string) bool { + checked = append(checked, candidate) + return candidate == "/bin/getent" + }) + if err != nil { + t.Fatalf("resolveGetent: %v", err) + } + if got != "/bin/getent" { + t.Fatalf("resolveGetent = %q, want /bin/getent", got) + } + wantChecked := []string{"/usr/bin/getent", "/bin/getent"} + if !reflect.DeepEqual(checked, wantChecked) { + t.Fatalf("checked = %#v, want fixed candidates %#v", checked, wantChecked) + } +} + +func TestListLocalAccountsWith_RunsFixedGetentPasswdAndParsesOutput(t *testing.T) { + data, err := os.ReadFile("testdata/passwd.txt") + if err != nil { + t.Fatal(err) + } + runner := &fakeGetentRunner{output: data} + + accounts, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) + if err != nil { + t.Fatalf("listLocalAccountsWith: %v", err) + } + if runner.name != "/usr/bin/getent" || !reflect.DeepEqual(runner.args, []string{"passwd"}) { + t.Fatalf("getent call = %q %#v, want /usr/bin/getent [passwd]", runner.name, runner.args) + } + if len(accounts) != 4 { + t.Fatalf("accounts = %d, want 4 deduplicated homes", len(accounts)) + } +} + +func TestListLocalAccountsWith_PropagatesGetentFailure(t *testing.T) { + runner := &fakeGetentRunner{err: errors.New("exit 2")} + _, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) + if err == nil || !strings.Contains(err.Error(), "getent passwd") || !strings.Contains(err.Error(), "exit 2") { + t.Fatalf("listLocalAccountsWith() error = %v, want getent failure context", err) + } +} + +func TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwnership(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + before, err := os.ReadFile("testdata/authorized_keys.before") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(authKeysPath, before, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(authKeysPath, 0o2640); err != nil { + t.Fatal(err) + } + var beforeStat unix.Stat_t + if err := unix.Stat(authKeysPath, &beforeStat); err != nil { + t.Fatal(err) + } + + removed, err := cleanLocalAccount(account) + if err != nil { + t.Fatalf("cleanLocalAccount: %v", err) + } + if removed != 2 { + t.Fatalf("removed = %d, want 2", removed) + } + want, err := os.ReadFile("testdata/authorized_keys.after") + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(authKeysPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("authorized_keys = %q, want %q", got, want) + } + var afterStat unix.Stat_t + if err := unix.Stat(authKeysPath, &afterStat); err != nil { + t.Fatal(err) + } + if afterStat.Uid != beforeStat.Uid || afterStat.Gid != beforeStat.Gid { + t.Fatalf("ownership = %d:%d, want %d:%d", afterStat.Uid, afterStat.Gid, beforeStat.Uid, beforeStat.Gid) + } + if gotMode, wantMode := afterStat.Mode&0o7777, beforeStat.Mode&0o7777; gotMode != wantMode { + t.Fatalf("mode = %#o, want full mode %#o", gotMode, wantMode) + } +} + +func TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + if err := os.WriteFile(authKeysPath, []byte("ssh-ed25519 AAAA_KEEP keep@example.com\n"), 0o600); err != nil { + t.Fatal(err) + } + var before unix.Stat_t + if err := unix.Stat(authKeysPath, &before); err != nil { + t.Fatal(err) + } + + removed, err := cleanLocalAccount(account) + if err != nil { + t.Fatalf("cleanLocalAccount: %v", err) + } + if removed != 0 { + t.Fatalf("removed = %d, want 0", removed) + } + var after unix.Stat_t + if err := unix.Stat(authKeysPath, &after); err != nil { + t.Fatal(err) + } + if before.Dev != after.Dev || before.Ino != after.Ino { + t.Fatalf("inode changed from %d:%d to %d:%d without markers", before.Dev, before.Ino, after.Dev, after.Ino) + } +} + +func TestSystemAuthorizedKeysCleaner_MissingSSHDirectoryIsSuccess(t *testing.T) { + account, _ := prepareAuthorizedKeys(t, false) + removed, err := cleanLocalAccount(account) + if err != nil || removed != 0 { + t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) + } +} + +func TestSystemAuthorizedKeysCleaner_MissingHomeComponentIsSuccess(t *testing.T) { + account := localAccount{Username: "alice", HomeDir: filepath.Join(t.TempDir(), "missing", "alice")} + removed, err := cleanLocalAccount(account) + if err != nil || removed != 0 { + t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) + } +} + +func TestSystemAuthorizedKeysCleaner_MissingAuthorizedKeysIsSuccess(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + if _, err := os.Stat(authKeysPath); !os.IsNotExist(err) { + t.Fatalf("authorized_keys unexpectedly exists: %v", err) + } + removed, err := cleanLocalAccount(account) + if err != nil || removed != 0 { + t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) + } +} + +func TestSystemAuthorizedKeysCleaner_RejectsSSHDirectorySymlink(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + target := filepath.Join(root, "target") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(home, ".ssh")); err != nil { + t.Fatal(err) + } + assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: home}) +} + +func TestSystemAuthorizedKeysCleaner_RejectsAuthorizedKeysSymlink(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(target, []byte("ssh-rsa KEEP\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, authKeysPath); err != nil { + t.Fatal(err) + } + assertUnsafeAccountPath(t, account) +} + +func TestSystemAuthorizedKeysCleaner_RejectsIntermediateHomeSymlink(t *testing.T) { + root := t.TempDir() + realParent := filepath.Join(root, "real") + home := filepath.Join(realParent, "alice") + if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + linkParent := filepath.Join(root, "linked") + if err := os.Symlink(realParent, linkParent); err != nil { + t.Fatal(err) + } + assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: filepath.Join(linkParent, "alice")}) +} + +func TestSystemAuthorizedKeysCleaner_RejectsFinalHomeSymlink(t *testing.T) { + root := t.TempDir() + realHome := filepath.Join(root, "real-home") + if err := os.MkdirAll(filepath.Join(realHome, ".ssh"), 0o700); err != nil { + t.Fatal(err) + } + linkedHome := filepath.Join(root, "linked-home") + if err := os.Symlink(realHome, linkedHome); err != nil { + t.Fatal(err) + } + assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: linkedHome}) +} + +func TestSystemAuthorizedKeysCleaner_RejectsParentTraversal(t *testing.T) { + root := t.TempDir() + account := localAccount{Username: "alice", HomeDir: root + "/missing/../alice"} + assertUnsafeAccountPath(t, account) +} + +func TestSystemAuthorizedKeysCleaner_RejectsFIFOWithoutBlocking(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + if err := unix.Mkfifo(authKeysPath, 0o600); err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + _, err := cleanLocalAccount(account) + done <- err + }() + select { + case err := <-done: + if err == nil { + t.Fatal("cleanLocalAccount() error = nil, want FIFO rejection") + } + case <-time.After(2 * time.Second): + t.Fatal("cleanLocalAccount blocked while inspecting FIFO") + } +} + +func TestSystemAuthorizedKeysCleaner_RejectsNonRegularAuthorizedKeys(t *testing.T) { + account, authKeysPath := prepareAuthorizedKeys(t, true) + if err := os.Mkdir(authKeysPath, 0o700); err != nil { + t.Fatal(err) + } + assertUnsafeAccountPath(t, account) +} + +func prepareAuthorizedKeys(t *testing.T, createSSH bool) (localAccount, string) { + t.Helper() + home := filepath.Join(t.TempDir(), "home", "alice") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + sshDir := filepath.Join(home, ".ssh") + if createSSH { + if err := os.Mkdir(sshDir, 0o700); err != nil { + t.Fatal(err) + } + } + return localAccount{Username: "alice", HomeDir: home}, filepath.Join(sshDir, "authorized_keys") +} + +func assertUnsafeAccountPath(t *testing.T, account localAccount) { + t.Helper() + if removed, err := cleanLocalAccount(account); err == nil { + t.Fatalf("removed, err = %d, nil; want unsafe-path rejection", removed) + } +} diff --git a/pkg/cmd/disablessh/localkeys_test.go b/pkg/cmd/disablessh/localkeys_test.go new file mode 100644 index 00000000..24228e81 --- /dev/null +++ b/pkg/cmd/disablessh/localkeys_test.go @@ -0,0 +1,278 @@ +package disablessh + +import ( + "bytes" + "context" + "errors" + "os" + "reflect" + "strings" + "testing" +) + +func TestParsePasswd_EnumeratesAndDeduplicatesHomes(t *testing.T) { + data, err := os.ReadFile("testdata/passwd.txt") + if err != nil { + t.Fatal(err) + } + + got, err := parsePasswd(data) + if err != nil { + t.Fatalf("parsePasswd: %v", err) + } + want := []localAccount{ + {Username: "root", HomeDir: "/root"}, + {Username: "alice", HomeDir: "/home/alice"}, + {Username: "svc-agent", HomeDir: "/var/lib/svc-agent"}, + {Username: "bob", HomeDir: "/home/shared"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsePasswd() = %#v, want %#v", got, want) + } +} + +func TestParsePasswd_RejectsMalformedRecord(t *testing.T) { + _, err := parsePasswd([]byte("alice:x:1000:1000:Alice:/home/alice\n")) + if err == nil || !strings.Contains(err.Error(), "line 1") { + t.Fatalf("parsePasswd() error = %v, want line context", err) + } +} + +func TestParsePasswd_RejectsRelativeHome(t *testing.T) { + _, err := parsePasswd([]byte("alice:x:1000:1000:Alice:home/alice:/bin/bash\n")) + if err == nil || !strings.Contains(err.Error(), "line 1") || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("parsePasswd() error = %v, want absolute-home error with line context", err) + } +} + +func TestStripBrevManagedAuthorizedKeyLines_PreservesUnrelatedBytes(t *testing.T) { + before, err := os.ReadFile("testdata/authorized_keys.before") + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile("testdata/authorized_keys.after") + if err != nil { + t.Fatal(err) + } + + got, removed := stripBrevManagedAuthorizedKeyLines(before) + if removed != 2 { + t.Fatalf("removed = %d, want 2", removed) + } + if !bytes.Equal(got, want) { + t.Fatalf("cleaned bytes = %q, want %q", got, want) + } +} + +func TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes(t *testing.T) { + data := []byte("ssh-ed25519 AAAA_KEEP keep@example.com\r\n\nssh-rsa AAAA_FINAL") + got, removed := stripBrevManagedAuthorizedKeyLines(data) + if removed != 0 { + t.Fatalf("removed = %d, want 0", removed) + } + if !bytes.Equal(got, data) { + t.Fatalf("cleaned bytes = %q, want original %q", got, data) + } +} + +func TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors(t *testing.T) { + accounts := []localAccount{ + {Username: "alice", HomeDir: "/home/alice"}, + {Username: "bob", HomeDir: "/home/bob"}, + {Username: "carol", HomeDir: "/home/carol"}, + } + var cleaned []localAccount + cleaner := systemLocalKeyCleaner{ + listAccounts: func(context.Context) ([]localAccount, error) { return accounts, nil }, + cleanAccount: func(account localAccount) (int, error) { + cleaned = append(cleaned, account) + switch account.Username { + case "alice": + return 0, errors.New("first failure") + case "bob": + return 2, nil + case "carol": + return 0, errors.New("third failure") + default: + return 0, nil + } + }, + } + + got, err := cleaner.RemoveBrevKeys(context.Background()) + if !reflect.DeepEqual(cleaned, accounts) { + t.Fatalf("cleaned accounts = %#v, want every account %#v", cleaned, accounts) + } + want := KeyCleanupResult{AccountsScanned: 1, AccountsChanged: 1, KeysRemoved: 2} + if got != want { + t.Fatalf("result = %#v, want %#v", got, want) + } + if err == nil { + t.Fatal("RemoveBrevKeys() error = nil, want joined account failures") + } + for _, text := range []string{"alice", "/home/alice", "first failure", "carol", "/home/carol", "third failure"} { + if !strings.Contains(err.Error(), text) { + t.Errorf("error %q does not contain %q", err, text) + } + } +} + +type fakeLocalKeyCleaner struct { + result KeyCleanupResult + err error + calls int +} + +func (f *fakeLocalKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) { + f.calls++ + return f.result, f.err +} + +type privilegedCommandCall struct { + name string + args []string +} + +type fakePrivilegedCommandRunner struct { + output []byte + err error + calls []privilegedCommandCall +} + +func (f *fakePrivilegedCommandRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, privilegedCommandCall{name: name, args: append([]string(nil), args...)}) + return append([]byte(nil), f.output...), f.err +} + +func TestPrivilegedLocalKeyCleaner_RootRunsDirectly(t *testing.T) { + direct := &fakeLocalKeyCleaner{result: KeyCleanupResult{AccountsScanned: 3, KeysRemoved: 2}} + runner := &fakePrivilegedCommandRunner{} + cleaner := privilegedLocalKeyCleaner{ + geteuid: func() int { return 0 }, + executable: func() (string, error) { t.Fatal("executable lookup called as root"); return "", nil }, + runner: runner, + direct: direct, + } + + got, err := cleaner.RemoveBrevKeys(context.Background()) + if err != nil { + t.Fatalf("RemoveBrevKeys: %v", err) + } + if got != direct.result || direct.calls != 1 || len(runner.calls) != 0 { + t.Fatalf("got %#v, direct calls %d, runner calls %#v", got, direct.calls, runner.calls) + } +} + +func TestPrivilegedLocalKeyCleaner_UsesFixedSudoCommandWhenNotRoot(t *testing.T) { + runner := &fakePrivilegedCommandRunner{output: []byte(`{"accounts_scanned":4,"accounts_changed":2,"keys_removed":3}`)} + cleaner := privilegedLocalKeyCleaner{ + geteuid: func() int { return 501 }, + executable: func() (string, error) { return "/opt/brev/bin/brev", nil }, + runner: runner, + direct: &fakeLocalKeyCleaner{}, + } + + got, err := cleaner.RemoveBrevKeys(context.Background()) + if err != nil { + t.Fatalf("RemoveBrevKeys: %v", err) + } + wantResult := KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3} + if got != wantResult { + t.Fatalf("result = %#v, want %#v", got, wantResult) + } + wantCalls := []privilegedCommandCall{{ + name: "sudo", + args: []string{"-n", "/opt/brev/bin/brev", "__brev-disable-ssh-cleanup"}, + }} + if !reflect.DeepEqual(runner.calls, wantCalls) { + t.Fatalf("runner calls = %#v, want %#v", runner.calls, wantCalls) + } +} + +func TestPrivilegedLocalKeyCleaner_RejectsInvalidJSON(t *testing.T) { + cleaner := privilegedLocalKeyCleaner{ + geteuid: func() int { return 501 }, + executable: func() (string, error) { return "/opt/brev/bin/brev", nil }, + runner: &fakePrivilegedCommandRunner{output: []byte("not json")}, + direct: &fakeLocalKeyCleaner{}, + } + + _, err := cleaner.RemoveBrevKeys(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode privileged Brev key cleanup result") { + t.Fatalf("RemoveBrevKeys() error = %v, want invalid JSON context", err) + } +} + +func TestExecPrivilegedCommandRunner_IncludesStderrOnFailure(t *testing.T) { + _, err := (execPrivilegedCommandRunner{}).Output( + context.Background(), + "/bin/sh", + "-c", + "printf 'sudo denied' >&2; exit 7", + ) + if err == nil || !strings.Contains(err.Error(), "sudo denied") { + t.Fatalf("Output() error = %v, want captured stderr", err) + } +} + +func TestRunLocalKeyCleanupHelper_IgnoresNormalCLIArguments(t *testing.T) { + cleaner := &fakeLocalKeyCleaner{} + var stdout bytes.Buffer + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"join", "--approve"}, &stdout, "linux", func() int { return 0 }, cleaner) + if err != nil || handled { + t.Fatalf("handled, err = %v, %v; want false, nil", handled, err) + } + if cleaner.calls != 0 || stdout.Len() != 0 { + t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) + } +} + +func TestRunLocalKeyCleanupHelper_RequiresExactToken(t *testing.T) { + cleaner := &fakeLocalKeyCleaner{} + var stdout bytes.Buffer + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup-extra"}, &stdout, "linux", func() int { return 0 }, cleaner) + if err != nil || handled { + t.Fatalf("handled, err = %v, %v; want false, nil", handled, err) + } + if cleaner.calls != 0 || stdout.Len() != 0 { + t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) + } +} + +func TestRunLocalKeyCleanupHelper_RejectsExtraArguments(t *testing.T) { + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup", "/home/alice"}, &bytes.Buffer{}, "linux", func() int { return 0 }, &fakeLocalKeyCleaner{}) + if !handled || err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("handled, err = %v, %v; want selected argument-count error", handled, err) + } +} + +func TestRunLocalKeyCleanupHelper_RejectsNonRoot(t *testing.T) { + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &bytes.Buffer{}, "linux", func() int { return 1000 }, &fakeLocalKeyCleaner{}) + if !handled || err == nil || !strings.Contains(err.Error(), "root") { + t.Fatalf("handled, err = %v, %v; want selected root error", handled, err) + } +} + +func TestRunLocalKeyCleanupHelper_RejectsNonLinux(t *testing.T) { + cleaner := &fakeLocalKeyCleaner{} + var stdout bytes.Buffer + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &stdout, "darwin", func() int { return 0 }, cleaner) + if !handled || err == nil || !strings.Contains(err.Error(), "only supported on Linux") { + t.Fatalf("handled, err = %v, %v; want selected Linux-only error", handled, err) + } + if cleaner.calls != 0 || stdout.Len() != 0 { + t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) + } +} + +func TestRunLocalKeyCleanupHelper_EmitsJSON(t *testing.T) { + cleaner := &fakeLocalKeyCleaner{result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}} + var stdout bytes.Buffer + handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &stdout, "linux", func() int { return 0 }, cleaner) + if err != nil || !handled { + t.Fatalf("handled, err = %v, %v; want true, nil", handled, err) + } + if want := "{\"accounts_scanned\":4,\"accounts_changed\":2,\"keys_removed\":3}\n"; stdout.String() != want { + t.Fatalf("stdout = %q, want JSON only %q", stdout.String(), want) + } +} diff --git a/pkg/cmd/disablessh/localkeys_unsupported.go b/pkg/cmd/disablessh/localkeys_unsupported.go new file mode 100644 index 00000000..30172522 --- /dev/null +++ b/pkg/cmd/disablessh/localkeys_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux + +package disablessh + +import ( + "context" + "fmt" +) + +func listLocalAccounts(context.Context) ([]localAccount, error) { + return nil, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") +} + +func cleanLocalAccount(localAccount) (int, error) { + return 0, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") +} diff --git a/pkg/cmd/disablessh/testdata/.gitattributes b/pkg/cmd/disablessh/testdata/.gitattributes new file mode 100644 index 00000000..081b797d --- /dev/null +++ b/pkg/cmd/disablessh/testdata/.gitattributes @@ -0,0 +1,2 @@ +authorized_keys.before -text whitespace=cr-at-eol +authorized_keys.after -text whitespace=cr-at-eol diff --git a/pkg/cmd/disablessh/testdata/authorized_keys.after b/pkg/cmd/disablessh/testdata/authorized_keys.after new file mode 100644 index 00000000..06fed3d6 --- /dev/null +++ b/pkg/cmd/disablessh/testdata/authorized_keys.after @@ -0,0 +1,3 @@ +from="10.0.0.0/8",no-agent-forwarding ssh-ed25519 AAAA_KEEP keep@example.com + +ssh-ed25519 AAAA_FINAL final@example.com diff --git a/pkg/cmd/disablessh/testdata/authorized_keys.before b/pkg/cmd/disablessh/testdata/authorized_keys.before new file mode 100644 index 00000000..1285fd87 --- /dev/null +++ b/pkg/cmd/disablessh/testdata/authorized_keys.before @@ -0,0 +1,5 @@ +from="10.0.0.0/8",no-agent-forwarding ssh-ed25519 AAAA_KEEP keep@example.com +ssh-ed25519 AAAA_CURRENT #brev-portID:port_1,brev-userID:user_1 + +ssh-rsa AAAA_LEGACY # brev-cli user_id=user_2 +ssh-ed25519 AAAA_FINAL final@example.com diff --git a/pkg/cmd/disablessh/testdata/passwd.txt b/pkg/cmd/disablessh/testdata/passwd.txt new file mode 100644 index 00000000..1e74b11b --- /dev/null +++ b/pkg/cmd/disablessh/testdata/passwd.txt @@ -0,0 +1,5 @@ +root:x:0:0:root:/root:/bin/bash +alice:x:1000:1000:Alice:/home/alice:/bin/bash +svc-agent:x:998:998:Service Agent:/var/lib/svc-agent:/usr/sbin/nologin +bob:x:1001:1001:Bob:/home/shared:/bin/zsh +carol:x:1002:1002:Carol:/home/shared:/bin/bash diff --git a/pkg/cmd/register/sshkeys.go b/pkg/cmd/register/sshkeys.go index 1188766d..2d211b27 100644 --- a/pkg/cmd/register/sshkeys.go +++ b/pkg/cmd/register/sshkeys.go @@ -159,7 +159,9 @@ type BrevAuthorizedKey struct { UserID string // from devplane brev-userID:... or legacy user_id= } -func isBrevManagedAuthorizedKeysLine(line string) bool { +// IsBrevManagedAuthorizedKeysLine reports whether a line was managed by a +// current or legacy Brev CLI SSH flow. +func IsBrevManagedAuthorizedKeysLine(line string) bool { return strings.Contains(line, BrevKeyPrefixLegacy) || strings.Contains(line, "#brev-portID:") } @@ -213,7 +215,7 @@ func ListBrevAuthorizedKeys(u *user.User) ([]BrevAuthorizedKey, error) { var keys []BrevAuthorizedKey for _, line := range strings.Split(string(data), "\n") { - if !isBrevManagedAuthorizedKeysLine(line) { + if !IsBrevManagedAuthorizedKeysLine(line) { continue } trimmed := strings.TrimSpace(line) @@ -529,7 +531,7 @@ func RemoveBrevAuthorizedKeys(u *user.User) ([]string, error) { var kept []string var removed []string for _, line := range strings.Split(string(existing), "\n") { - if isBrevManagedAuthorizedKeysLine(line) { + if IsBrevManagedAuthorizedKeysLine(line) { if trimmed := strings.TrimSpace(line); trimmed != "" { removed = append(removed, trimmed) } diff --git a/pkg/cmd/register/sshkeys_test.go b/pkg/cmd/register/sshkeys_test.go index acecb74a..b9db7e47 100644 --- a/pkg/cmd/register/sshkeys_test.go +++ b/pkg/cmd/register/sshkeys_test.go @@ -43,6 +43,28 @@ func TestDevplaneAuthorizedKeysComment(t *testing.T) { } } +func TestIsBrevManagedAuthorizedKeysLine(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + {name: "current marker", line: "ssh-ed25519 AAAA #brev-portID:port_1,brev-userID:user_1", want: true}, + {name: "legacy marker", line: "ssh-rsa AAAA # brev-cli user_id=user_1", want: true}, + {name: "unrelated key", line: "ssh-rsa AAAA user@example.com", want: false}, + {name: "blank line", line: "", want: false}, + {name: "unrelated comment", line: "# managed by another tool", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsBrevManagedAuthorizedKeysLine(tt.line); got != tt.want { + t.Fatalf("IsBrevManagedAuthorizedKeysLine(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + func TestListBrevAuthorizedKeys_ParsesDevplaneFormat(t *testing.T) { u := tempUser(t) seedKeys(t, u, strings.Join([]string{ From 745fa2b3c8c6e0c785129fc511ca4d9607161140 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 14:30:30 -0700 Subject: [PATCH 11/23] fix: harden privileged key cleanup commit --- pkg/cmd/disablessh/localkeys_linux.go | 278 +++++++++++++++++++-- pkg/cmd/disablessh/localkeys_linux_test.go | 133 ++++++++++ 2 files changed, 396 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/disablessh/localkeys_linux.go b/pkg/cmd/disablessh/localkeys_linux.go index 417c5520..cbb159cf 100644 --- a/pkg/cmd/disablessh/localkeys_linux.go +++ b/pkg/cmd/disablessh/localkeys_linux.go @@ -3,6 +3,7 @@ package disablessh import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -69,6 +70,9 @@ func listLocalAccountsWith(ctx context.Context, getentPath string, runner getent if err != nil { return nil, fmt.Errorf("parse getent passwd output: %w", err) } + if len(accounts) == 0 { + return nil, fmt.Errorf("getent passwd returned no local accounts") + } return accounts, nil } @@ -141,7 +145,7 @@ func cleanLocalAccount(account localAccount) (int, error) { return 0, nil } - if err := replaceAuthorizedKeys(sshFD, cleaned, opened); err != nil { + if err := replaceAuthorizedKeys(sshFD, authorizedKeysFD, data, cleaned, opened); err != nil { return 0, fmt.Errorf("replace authorized_keys under home %q: %w", account.HomeDir, err) } return removed, nil @@ -192,19 +196,54 @@ func sameFileIdentity(a, b unix.Stat_t) bool { return a.Dev == b.Dev && a.Ino == b.Ino && a.Mode&unix.S_IFMT == b.Mode&unix.S_IFMT } -func replaceAuthorizedKeys(sshFD int, cleaned []byte, original unix.Stat_t) error { +type replaceAuthorizedKeysHooks struct { + beforeExchange func(sshFD int, tempName string) error +} + +func replaceAuthorizedKeys( + sshFD int, + originalFD int, + originalData []byte, + cleaned []byte, + original unix.Stat_t, +) error { + return replaceAuthorizedKeysWithHooks( + sshFD, + originalFD, + originalData, + cleaned, + original, + replaceAuthorizedKeysHooks{}, + ) +} + +func replaceAuthorizedKeysWithHooks( + sshFD int, + originalFD int, + originalData []byte, + cleaned []byte, + original unix.Stat_t, + hooks replaceAuthorizedKeysHooks, +) (retErr error) { tempFD, tempName, err := createRandomTempFile(sshFD) if err != nil { return err } - renamed := false + var tempCleanupIdentity unix.Stat_t + tempIdentityKnown := false + if err := unix.Fstat(tempFD, &tempCleanupIdentity); err != nil { + closeDescriptor(tempFD) + return fmt.Errorf("inspect created temporary authorized_keys: %w", err) + } + tempIdentityKnown = true + var tempStat unix.Stat_t defer func() { - if tempFD >= 0 { - closeDescriptor(tempFD) - } - if !renamed { - _ = unix.Unlinkat(sshFD, tempName, 0) + if tempIdentityKnown { + if _, cleanupErr := unlinkNameIfMatches(sshFD, tempName, tempCleanupIdentity); cleanupErr != nil { + retErr = errors.Join(retErr, fmt.Errorf("remove temporary authorized_keys: %w", cleanupErr)) + } } + closeDescriptor(tempFD) }() if err := writeAll(tempFD, cleaned); err != nil { @@ -219,21 +258,230 @@ func replaceAuthorizedKeys(sshFD int, cleaned []byte, original unix.Stat_t) erro if err := unix.Fsync(tempFD); err != nil { return fmt.Errorf("sync temporary authorized_keys: %w", err) } - if err := unix.Close(tempFD); err != nil { - tempFD = -1 - return fmt.Errorf("close temporary authorized_keys: %w", err) + if err := unix.Fstat(tempFD, &tempStat); err != nil { + return fmt.Errorf("inspect temporary authorized_keys: %w", err) + } + if !isRegular(tempStat) { + return fmt.Errorf("temporary authorized_keys is not a regular file") + } + + if err := verifyDescriptorState(originalFD, original, originalData); err != nil { + return fmt.Errorf("authorized_keys changed before commit: %w", err) + } + if err := verifyDescriptorMetadata(tempFD, tempStat); err != nil { + return fmt.Errorf("temporary authorized_keys changed before commit: %w", err) + } + if err := verifyNameMatches(sshFD, authorizedKeysName, original); err != nil { + return fmt.Errorf("authorized_keys changed before commit: %w", err) + } + if err := verifyNameMatches(sshFD, tempName, tempStat); err != nil { + return fmt.Errorf("temporary authorized_keys changed before commit: %w", err) + } + if hooks.beforeExchange != nil { + if err := hooks.beforeExchange(sshFD, tempName); err != nil { + return fmt.Errorf("run authorized_keys commit hook: %w", err) + } + } + + if err := unix.Renameat2( + sshFD, + tempName, + sshFD, + authorizedKeysName, + unix.RENAME_EXCHANGE, + ); err != nil { + return fmt.Errorf("exchange temporary authorized_keys: %w", err) + } + + postAuthorized, postTemp, verificationErr := verifyExchangedAuthorizedKeys( + sshFD, + originalFD, + tempFD, + tempName, + original, + tempStat, + originalData, + ) + if verificationErr != nil { + rollbackErr := rollbackAuthorizedKeysExchange(sshFD, tempName, postAuthorized, postTemp) + if rollbackErr != nil { + return errors.Join( + fmt.Errorf("authorized_keys changed during commit: %w", verificationErr), + fmt.Errorf("restore authorized_keys exchange: %w", rollbackErr), + ) + } + return fmt.Errorf("authorized_keys changed during commit: %w", verificationErr) } - tempFD = -1 - if err := unix.Renameat(sshFD, tempName, sshFD, authorizedKeysName); err != nil { - return fmt.Errorf("rename temporary authorized_keys: %w", err) + + unlinked, err := unlinkNameIfMatches(sshFD, tempName, original) + if err != nil { + return fmt.Errorf("remove exchanged original authorized_keys: %w", err) + } + if !unlinked { + return fmt.Errorf("authorized_keys changed during commit before removing exchanged original") } - renamed = true if err := unix.Fsync(sshFD); err != nil { return fmt.Errorf("sync .ssh directory: %w", err) } return nil } +func verifyExchangedAuthorizedKeys( + sshFD int, + originalFD int, + tempFD int, + tempName string, + original unix.Stat_t, + temp unix.Stat_t, + originalData []byte, +) (unix.Stat_t, unix.Stat_t, error) { + postAuthorized, authorizedErr := statName(sshFD, authorizedKeysName) + postTemp, tempErr := statName(sshFD, tempName) + var verificationErrs []error + if authorizedErr != nil { + verificationErrs = append(verificationErrs, fmt.Errorf("inspect exchanged authorized_keys: %w", authorizedErr)) + } else if !sameFileIdentity(postAuthorized, temp) { + verificationErrs = append(verificationErrs, fmt.Errorf("exchanged authorized_keys does not match verified temporary file")) + } + if tempErr != nil { + verificationErrs = append(verificationErrs, fmt.Errorf("inspect exchanged original authorized_keys: %w", tempErr)) + } else if !sameFileIdentity(postTemp, original) { + verificationErrs = append(verificationErrs, fmt.Errorf("exchanged original authorized_keys does not match opened file")) + } + if err := verifyDescriptorState(originalFD, original, originalData); err != nil { + verificationErrs = append(verificationErrs, fmt.Errorf("opened original authorized_keys changed: %w", err)) + } + if err := verifyDescriptorMetadata(tempFD, temp); err != nil { + verificationErrs = append(verificationErrs, fmt.Errorf("opened temporary authorized_keys changed: %w", err)) + } + return postAuthorized, postTemp, errors.Join(verificationErrs...) +} + +func rollbackAuthorizedKeysExchange( + sshFD int, + tempName string, + postAuthorized unix.Stat_t, + postTemp unix.Stat_t, +) error { + currentAuthorized, authorizedErr := statName(sshFD, authorizedKeysName) + currentTemp, tempErr := statName(sshFD, tempName) + if authorizedErr != nil || tempErr != nil { + var inspectErrs []error + if authorizedErr != nil { + inspectErrs = append(inspectErrs, fmt.Errorf("inspect current authorized_keys before rollback: %w", authorizedErr)) + } + if tempErr != nil { + inspectErrs = append(inspectErrs, fmt.Errorf("inspect current temporary name before rollback: %w", tempErr)) + } + return errors.Join(inspectErrs...) + } + if !sameFileIdentity(currentAuthorized, postAuthorized) || !sameFileIdentity(currentTemp, postTemp) { + return fmt.Errorf("directory entries changed again before rollback") + } + if err := unix.Renameat2( + sshFD, + tempName, + sshFD, + authorizedKeysName, + unix.RENAME_EXCHANGE, + ); err != nil { + return fmt.Errorf("exchange directory entries back: %w", err) + } + if err := verifyNameMatches(sshFD, authorizedKeysName, postTemp); err != nil { + return fmt.Errorf("verify restored authorized_keys: %w", err) + } + if err := verifyNameMatches(sshFD, tempName, postAuthorized); err != nil { + return fmt.Errorf("verify restored temporary name: %w", err) + } + if err := unix.Fsync(sshFD); err != nil { + return fmt.Errorf("sync restored .ssh directory: %w", err) + } + return nil +} + +func verifyDescriptorState(fd int, expected unix.Stat_t, expectedData []byte) error { + if err := verifyDescriptorMetadata(fd, expected); err != nil { + return err + } + data, err := readAllAt(fd) + if err != nil { + return fmt.Errorf("read opened file: %w", err) + } + if !bytes.Equal(data, expectedData) { + return fmt.Errorf("opened file contents changed") + } + return nil +} + +func verifyDescriptorMetadata(fd int, expected unix.Stat_t) error { + var current unix.Stat_t + if err := unix.Fstat(fd, ¤t); err != nil { + return fmt.Errorf("inspect opened file: %w", err) + } + if !isRegular(current) || !sameFileIdentity(current, expected) { + return fmt.Errorf("opened file identity changed") + } + if current.Uid != expected.Uid || current.Gid != expected.Gid || current.Mode&0o7777 != expected.Mode&0o7777 { + return fmt.Errorf("opened file ownership or mode changed") + } + return nil +} + +func verifyNameMatches(dirFD int, name string, expected unix.Stat_t) error { + current, err := statName(dirFD, name) + if err != nil { + return err + } + if !isRegular(current) || !sameFileIdentity(current, expected) { + return fmt.Errorf("%q no longer identifies the verified regular file", name) + } + return nil +} + +func statName(dirFD int, name string) (unix.Stat_t, error) { + var stat unix.Stat_t + if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return unix.Stat_t{}, err + } + return stat, nil +} + +func unlinkNameIfMatches(dirFD int, name string, expected unix.Stat_t) (bool, error) { + current, err := statName(dirFD, name) + if errors.Is(err, unix.ENOENT) { + return false, nil + } + if err != nil { + return false, err + } + if !sameFileIdentity(current, expected) { + return false, nil + } + if err := unix.Unlinkat(dirFD, name, 0); err != nil { + return false, err + } + return true, nil +} + +func readAllAt(fd int) ([]byte, error) { + const chunkSize = 32 * 1024 + data := make([]byte, 0, chunkSize) + buffer := make([]byte, chunkSize) + for { + n, err := unix.Pread(fd, buffer, int64(len(data))) + if errors.Is(err, unix.EINTR) { + continue + } + if err != nil { + return nil, err + } + if n == 0 { + return data, nil + } + data = append(data, buffer[:n]...) + } +} + func createRandomTempFile(sshFD int) (int, string, error) { for range 128 { random := make([]byte, 16) diff --git a/pkg/cmd/disablessh/localkeys_linux_test.go b/pkg/cmd/disablessh/localkeys_linux_test.go index 2f9c34cd..e4b5649c 100644 --- a/pkg/cmd/disablessh/localkeys_linux_test.go +++ b/pkg/cmd/disablessh/localkeys_linux_test.go @@ -74,6 +74,16 @@ func TestListLocalAccountsWith_PropagatesGetentFailure(t *testing.T) { } } +func TestListLocalAccountsWith_RejectsEmptyEnumeration(t *testing.T) { + for _, output := range [][]byte{nil, []byte("\n\r\n")} { + runner := &fakeGetentRunner{output: output} + _, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) + if err == nil || !strings.Contains(err.Error(), "returned no local accounts") { + t.Fatalf("listLocalAccountsWith(%q) error = %v, want empty-enumeration failure", output, err) + } + } +} + func TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwnership(t *testing.T) { account, authKeysPath := prepareAuthorizedKeys(t, true) before, err := os.ReadFile("testdata/authorized_keys.before") @@ -90,6 +100,9 @@ func TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwners if err := unix.Stat(authKeysPath, &beforeStat); err != nil { t.Fatal(err) } + if got := beforeStat.Mode & 0o7777; got != 0o2640 { + t.Skipf("filesystem cannot establish setgid test precondition: mode = %#o, want %#o", got, uint32(0o2640)) + } removed, err := cleanLocalAccount(account) if err != nil { @@ -121,6 +134,101 @@ func TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwners } } +func TestReplaceAuthorizedKeys_RejectsSubstitutedTempSource(t *testing.T) { + _, authKeysPath := prepareAuthorizedKeys(t, true) + original := []byte("ssh-ed25519 KEEP keep@example.com #brev-portID:old\n") + if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { + t.Fatal(err) + } + sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) + defer closeDescriptor(sshFD) + defer closeDescriptor(originalFD) + + err := replaceAuthorizedKeysWithHooks( + sshFD, + originalFD, + original, + []byte("ssh-ed25519 KEEP keep@example.com\n"), + originalStat, + replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, tempName string) error { + if err := unix.Unlinkat(sshFD, tempName, 0); err != nil { + return err + } + attackerFD, err := unix.Openat( + sshFD, + tempName, + unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0o600, + ) + if err != nil { + return err + } + defer closeDescriptor(attackerFD) + return writeAll(attackerFD, []byte("attacker-controlled source\n")) + }}, + ) + if err == nil || !strings.Contains(err.Error(), "changed during commit") { + t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want source-substitution failure", err) + } + got, readErr := os.ReadFile(authKeysPath) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(got, original) { + t.Fatalf("authorized_keys = %q, want original destination preserved %q", got, original) + } +} + +func TestReplaceAuthorizedKeys_RejectsSubstitutedDestinationWithoutDestroyingIt(t *testing.T) { + _, authKeysPath := prepareAuthorizedKeys(t, true) + original := []byte("ssh-ed25519 OLD old@example.com #brev-portID:old\n") + if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { + t.Fatal(err) + } + sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) + defer closeDescriptor(sshFD) + defer closeDescriptor(originalFD) + replacement := []byte("ssh-ed25519 NEW concurrent@example.com\n") + + err := replaceAuthorizedKeysWithHooks( + sshFD, + originalFD, + original, + []byte("ssh-ed25519 OLD old@example.com\n"), + originalStat, + replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, _ string) error { + const replacementName = "authorized_keys.concurrent-replacement" + replacementFD, err := unix.Openat( + sshFD, + replacementName, + unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0o600, + ) + if err != nil { + return err + } + if err := writeAll(replacementFD, replacement); err != nil { + closeDescriptor(replacementFD) + return err + } + if err := unix.Close(replacementFD); err != nil { + return err + } + return unix.Renameat(sshFD, replacementName, sshFD, authorizedKeysName) + }}, + ) + if err == nil || !strings.Contains(err.Error(), "changed during commit") { + t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want destination-substitution failure", err) + } + got, readErr := os.ReadFile(authKeysPath) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(got, replacement) { + t.Fatalf("authorized_keys = %q, want concurrent replacement preserved %q", got, replacement) + } +} + func TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite(t *testing.T) { account, authKeysPath := prepareAuthorizedKeys(t, true) if err := os.WriteFile(authKeysPath, []byte("ssh-ed25519 AAAA_KEEP keep@example.com\n"), 0o600); err != nil { @@ -279,6 +387,31 @@ func prepareAuthorizedKeys(t *testing.T, createSSH bool) (localAccount, string) return localAccount{Username: "alice", HomeDir: home}, filepath.Join(sshDir, "authorized_keys") } +func openReplacementTestDescriptors(t *testing.T, authorizedKeysPath string) (int, int, unix.Stat_t) { + t.Helper() + sshFD, err := unix.Open(filepath.Dir(authorizedKeysPath), directoryOpenFlags(), 0) + if err != nil { + t.Fatal(err) + } + originalFD, err := unix.Openat( + sshFD, + authorizedKeysName, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, + 0, + ) + if err != nil { + closeDescriptor(sshFD) + t.Fatal(err) + } + var originalStat unix.Stat_t + if err := unix.Fstat(originalFD, &originalStat); err != nil { + closeDescriptor(originalFD) + closeDescriptor(sshFD) + t.Fatal(err) + } + return sshFD, originalFD, originalStat +} + func assertUnsafeAccountPath(t *testing.T, account localAccount) { t.Helper() if removed, err := cleanLocalAccount(account); err == nil { From a0e4d2dd78c05132dccb7c79c691e168b9fff29c Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 14:39:42 -0700 Subject: [PATCH 12/23] fix: verify privileged cleanup bytes --- pkg/cmd/disablessh/localkeys_linux.go | 8 +++-- pkg/cmd/disablessh/localkeys_linux_test.go | 42 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/disablessh/localkeys_linux.go b/pkg/cmd/disablessh/localkeys_linux.go index cbb159cf..9470ef28 100644 --- a/pkg/cmd/disablessh/localkeys_linux.go +++ b/pkg/cmd/disablessh/localkeys_linux.go @@ -268,7 +268,7 @@ func replaceAuthorizedKeysWithHooks( if err := verifyDescriptorState(originalFD, original, originalData); err != nil { return fmt.Errorf("authorized_keys changed before commit: %w", err) } - if err := verifyDescriptorMetadata(tempFD, tempStat); err != nil { + if err := verifyDescriptorState(tempFD, tempStat, cleaned); err != nil { return fmt.Errorf("temporary authorized_keys changed before commit: %w", err) } if err := verifyNameMatches(sshFD, authorizedKeysName, original); err != nil { @@ -301,6 +301,7 @@ func replaceAuthorizedKeysWithHooks( original, tempStat, originalData, + cleaned, ) if verificationErr != nil { rollbackErr := rollbackAuthorizedKeysExchange(sshFD, tempName, postAuthorized, postTemp) @@ -334,6 +335,7 @@ func verifyExchangedAuthorizedKeys( original unix.Stat_t, temp unix.Stat_t, originalData []byte, + cleaned []byte, ) (unix.Stat_t, unix.Stat_t, error) { postAuthorized, authorizedErr := statName(sshFD, authorizedKeysName) postTemp, tempErr := statName(sshFD, tempName) @@ -351,7 +353,7 @@ func verifyExchangedAuthorizedKeys( if err := verifyDescriptorState(originalFD, original, originalData); err != nil { verificationErrs = append(verificationErrs, fmt.Errorf("opened original authorized_keys changed: %w", err)) } - if err := verifyDescriptorMetadata(tempFD, temp); err != nil { + if err := verifyDescriptorState(tempFD, temp, cleaned); err != nil { verificationErrs = append(verificationErrs, fmt.Errorf("opened temporary authorized_keys changed: %w", err)) } return postAuthorized, postTemp, errors.Join(verificationErrs...) @@ -492,7 +494,7 @@ func createRandomTempFile(sshFD int) (int, string, error) { fd, err := unix.Openat( sshFD, name, - unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, + unix.O_CREAT|unix.O_EXCL|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600, ) if err == nil { diff --git a/pkg/cmd/disablessh/localkeys_linux_test.go b/pkg/cmd/disablessh/localkeys_linux_test.go index e4b5649c..85323a4b 100644 --- a/pkg/cmd/disablessh/localkeys_linux_test.go +++ b/pkg/cmd/disablessh/localkeys_linux_test.go @@ -229,6 +229,48 @@ func TestReplaceAuthorizedKeys_RejectsSubstitutedDestinationWithoutDestroyingIt( } } +func TestReplaceAuthorizedKeys_RejectsInPlaceTempContentMutation(t *testing.T) { + _, authKeysPath := prepareAuthorizedKeys(t, true) + original := []byte("ssh-ed25519 OLD old@example.com #brev-portID:old\n") + if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { + t.Fatal(err) + } + sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) + defer closeDescriptor(sshFD) + defer closeDescriptor(originalFD) + + err := replaceAuthorizedKeysWithHooks( + sshFD, + originalFD, + original, + []byte("ssh-ed25519 OLD old@example.com\n"), + originalStat, + replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, tempName string) error { + mutatorFD, err := unix.Openat( + sshFD, + tempName, + unix.O_WRONLY|unix.O_TRUNC|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0, + ) + if err != nil { + return err + } + defer closeDescriptor(mutatorFD) + return writeAll(mutatorFD, []byte("attacker-mutated bytes\n")) + }}, + ) + if err == nil || !strings.Contains(err.Error(), "changed during commit") { + t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want temp-content mutation failure", err) + } + got, readErr := os.ReadFile(authKeysPath) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(got, original) { + t.Fatalf("authorized_keys = %q, want original destination restored %q", got, original) + } +} + func TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite(t *testing.T) { account, authKeysPath := prepareAuthorizedKeys(t, true) if err := os.WriteFile(authKeysPath, []byte("ssh-ed25519 AAAA_KEEP keep@example.com\n"), 0o600); err != nil { From 6ddd4675f8ad6fc1e859dfe6fc0e8abf5868761c Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 14:56:46 -0700 Subject: [PATCH 13/23] feat: add node-wide disable-ssh command --- pkg/cmd/cmd.go | 2 + pkg/cmd/cmd_test.go | 13 + pkg/cmd/disablessh/disablessh.go | 198 +++++++++ pkg/cmd/disablessh/disablessh_test.go | 603 ++++++++++++++++++++++++++ 4 files changed, 816 insertions(+) create mode 100644 pkg/cmd/disablessh/disablessh.go create mode 100644 pkg/cmd/disablessh/disablessh_test.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 8f98509f..33a7b68f 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -15,6 +15,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/copy" "github.com/brevdev/brev-cli/pkg/cmd/delete" "github.com/brevdev/brev-cli/pkg/cmd/deregister" + "github.com/brevdev/brev-cli/pkg/cmd/disablessh" "github.com/brevdev/brev-cli/pkg/cmd/enablessh" "github.com/brevdev/brev-cli/pkg/cmd/envvars" "github.com/brevdev/brev-cli/pkg/cmd/exec" @@ -320,6 +321,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(deregister.NewCmdDeregister(t, externalNodeCmdStore)) cmd.AddCommand(upgrade.NewCmdUpgrade(t, noLoginCmdStore)) cmd.AddCommand(enablessh.NewCmdEnableSSH(t, externalNodeCmdStore)) + cmd.AddCommand(disablessh.NewCmdDisableSSH(t, externalNodeCmdStore)) cmd.AddCommand(grantssh.NewCmdGrantSSH(t, externalNodeCmdStore)) cmd.AddCommand(revokessh.NewCmdRevokeSSH(t, externalNodeCmdStore)) cmd.AddCommand(runtasks.NewCmdRunTasks(t, noLoginCmdStore)) diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go index dbee8d2b..51d8e1cd 100644 --- a/pkg/cmd/cmd_test.go +++ b/pkg/cmd/cmd_test.go @@ -39,6 +39,19 @@ func TestNewBrevCommand_BYONCommandSurface(t *testing.T) { require.NoError(t, err) require.Equal(t, "join", join.Name()) require.Same(t, join, register) + + disableSSH, _, err := root.Find([]string{"disable-ssh"}) + require.NoError(t, err) + require.Equal(t, "disable-ssh", disableSSH.Name()) + require.Empty(t, disableSSH.Aliases) + + var disableSSHCount int + for _, command := range root.Commands() { + if command.Name() == "disable-ssh" { + disableSSHCount++ + } + } + require.Equal(t, 1, disableSSHCount) } func TestEmailCachingAuthStore_SaveCachesEmail(t *testing.T) { diff --git a/pkg/cmd/disablessh/disablessh.go b/pkg/cmd/disablessh/disablessh.go new file mode 100644 index 00000000..4a814894 --- /dev/null +++ b/pkg/cmd/disablessh/disablessh.go @@ -0,0 +1,198 @@ +// Package disablessh provides the node-wide brev disable-ssh command. +package disablessh + +import ( + "context" + "fmt" + "io" + + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/config" + "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/sudo" + "github.com/brevdev/brev-cli/pkg/terminal" + + "github.com/spf13/cobra" +) + +// DisableSSHStore defines the authenticated store methods needed by disable-ssh. +type DisableSSHStore interface { + GetCurrentUser() (*entity.User, error) + GetAccessToken() (string, error) +} + +type disableSSHDeps struct { + platform externalnode.PlatformChecker + confirmer terminal.Confirmer + gater sudo.Gater + tunnel register.NetBirdConnector + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore + keyCleaner localKeyCleaner +} + +func defaultDisableSSHDeps() disableSSHDeps { + return disableSSHDeps{ + platform: register.LinuxPlatform{}, + confirmer: register.TerminalPrompter{}, + gater: sudo.Default, + tunnel: register.Netbird{}, + nodeClients: register.DefaultNodeClientFactory{}, + registrationStore: register.NewFileRegistrationStore(), + keyCleaner: newPrivilegedLocalKeyCleaner(), + } +} + +// NewCmdDisableSSH creates the canonical node-wide disable-ssh command. +func NewCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore) *cobra.Command { + return newCmdDisableSSH(t, store, defaultDisableSSHDeps()) +} + +func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableSSHDeps) *cobra.Command { + var approveFlag bool + cmd := &cobra.Command{ + Annotations: map[string]string{"configuration": ""}, + Use: "disable-ssh", + DisableFlagsInUseLine: true, + Short: "Disable all Brev-managed SSH access on this node", + Long: "Disable every Brev-managed SSH credential on this joined node without changing Brev network membership or the SSH daemon.", + Example: " brev disable-ssh\n brev disable-ssh --approve", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runDisableSSH(cmd.Context(), t, cmd.ErrOrStderr(), store, deps, approveFlag) + }, + } + cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip confirmation prompt (assume yes)") + return cmd +} + +func runDisableSSH( + ctx context.Context, + t *terminal.Terminal, + warnings io.Writer, + store DisableSSHStore, + deps disableSSHDeps, + skipConfirm bool, +) error { //nolint:funlen // Ordered teardown state machine is intentionally explicit. + if !deps.platform.IsCompatible() { + return fmt.Errorf("brev disable-ssh is only supported on Linux") + } + + exists, err := deps.registrationStore.Exists() + if err != nil { + return fmt.Errorf("check joined-device registration: %w", err) + } + if !exists { + return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) + } + + reg, err := deps.registrationStore.Load() + if err != nil { + return fmt.Errorf("read joined-device registration: %w", err) + } + if _, err := store.GetCurrentUser(); err != nil { + return breverrors.WrapAndTrace(err) + } + + node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, store, reg) + if err != nil { + return fmt.Errorf("disable SSH failed: %w", err) + } + accesses := snapshotSSHAccess(node.GetSshAccess()) + linuxAccounts := distinctLinuxAccountCount(accesses) + + t.Vprint("") + t.Vprint(t.White("══════════════════════════════════════════════════")) + t.Vprint(t.White(" Disabling Brev-managed SSH access")) + t.Vprint(t.White("══════════════════════════════════════════════════")) + t.Vprint("") + t.Vprintf(" Node: %s (%s)\n", node.GetName(), node.GetExternalNodeId()) + t.Vprintf(" SSH grants: %d\n", len(accesses)) + t.Vprintf(" Linux accounts: %d\n", linuxAccounts) + t.Vprint("") + if warnings == nil { + warnings = io.Discard + } + _, _ = fmt.Fprintln(warnings, "Warning: this is a node-wide operation that removes all Brev-managed SSH credentials on this node.") + _, _ = fmt.Fprintln(warnings, "Warning: active SSH sessions are not forcibly terminated.") + + if !skipConfirm && !deps.confirmer.ConfirmYesNo("Disable all Brev-managed SSH access on this node?") { + t.Vprint("Disable SSH canceled.") + return nil + } + + if err := deps.gater.Gate(t, deps.confirmer, "Node-wide Brev SSH cleanup", true); err != nil { + return fmt.Errorf("sudo issue: %w", err) + } + + if len(accesses) > 0 { + if err := deps.tunnel.EnsureConnected(ctx); err != nil { + return fmt.Errorf("disable SSH requires a connected Brev tunnel: %w", err) + } + client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + if err := revokeSSHAccesses(ctx, client, reg.ExternalNodeID, accesses); err != nil { + return err + } + } + + result, err := deps.keyCleaner.RemoveBrevKeys(ctx) + if err != nil { + return fmt.Errorf("disable SSH local key cleanup incomplete: %w", err) + } + t.Vprintf("%s SSH access disabled: %d keys removed; %d accounts changed.\n", t.Green(" ✓"), result.KeysRemoved, result.AccountsChanged) + return nil +} + +func revokeSSHAccesses( + ctx context.Context, + client nodev1connect.ExternalNodeServiceClient, + nodeID string, + accesses []*nodev1.SSHAccess, +) error { + var revokeErrs []error + for _, access := range accesses { + _, err := client.RevokeNodeSSHAccess(ctx, connect.NewRequest(&nodev1.RevokeNodeSSHAccessRequest{ + ExternalNodeId: nodeID, + PortId: access.GetPortId(), + UserId: access.GetUserId(), + LinuxUser: access.GetLinuxUser(), + })) + if err != nil { + revokeErrs = append(revokeErrs, fmt.Errorf( + "revoke SSH access for user %q, Linux account %q, port %q: %w", + access.GetUserId(), + access.GetLinuxUser(), + access.GetPortId(), + err, + )) + } + } + if err := breverrors.Join(revokeErrs...); err != nil { + return fmt.Errorf("disable SSH backend cleanup incomplete: %w", err) + } + return nil +} + +func snapshotSSHAccess(accesses []*nodev1.SSHAccess) []*nodev1.SSHAccess { + snapshot := make([]*nodev1.SSHAccess, 0, len(accesses)) + for _, access := range accesses { + if access != nil { + snapshot = append(snapshot, access) + } + } + return snapshot +} + +func distinctLinuxAccountCount(accesses []*nodev1.SSHAccess) int { + accounts := make(map[string]struct{}, len(accesses)) + for _, access := range accesses { + accounts[access.GetLinuxUser()] = struct{}{} + } + return len(accounts) +} diff --git a/pkg/cmd/disablessh/disablessh_test.go b/pkg/cmd/disablessh/disablessh_test.go new file mode 100644 index 00000000..096e8aec --- /dev/null +++ b/pkg/cmd/disablessh/disablessh_test.go @@ -0,0 +1,603 @@ +package disablessh + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" + nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + + "github.com/brevdev/brev-cli/pkg/cmd/register" + "github.com/brevdev/brev-cli/pkg/entity" + "github.com/brevdev/brev-cli/pkg/externalnode" + "github.com/brevdev/brev-cli/pkg/terminal" +) + +type disableSSHTestPlatform struct { + compatible bool + events *[]string +} + +func (p *disableSSHTestPlatform) IsCompatible() bool { + recordDisableSSHEvent(p.events, "platform") + return p.compatible +} + +type disableSSHTestStore struct { + events *[]string + currentUserCalls int + accessTokenCalls int + currentUserErr error +} + +func (s *disableSSHTestStore) GetCurrentUser() (*entity.User, error) { + s.currentUserCalls++ + recordDisableSSHEvent(s.events, "auth") + if s.currentUserErr != nil { + return nil, s.currentUserErr + } + return &entity.User{ID: "user_current"}, nil +} + +func (s *disableSSHTestStore) GetAccessToken() (string, error) { + s.accessTokenCalls++ + return "token", nil +} + +type disableSSHTestRegistrationStore struct { + events *[]string + exists bool + existsErr error + loadErr error + reg *register.DeviceRegistration + saveCalls int + deleteCalls int +} + +func (s *disableSSHTestRegistrationStore) Exists() (bool, error) { + recordDisableSSHEvent(s.events, "registration-exists") + return s.exists, s.existsErr +} + +func (s *disableSSHTestRegistrationStore) Load() (*register.DeviceRegistration, error) { + recordDisableSSHEvent(s.events, "registration-load") + if s.loadErr != nil { + return nil, s.loadErr + } + return s.reg, nil +} + +func (s *disableSSHTestRegistrationStore) Save(*register.DeviceRegistration) error { + s.saveCalls++ + recordDisableSSHEvent(s.events, "registration-save") + return nil +} + +func (s *disableSSHTestRegistrationStore) Delete() error { + s.deleteCalls++ + recordDisableSSHEvent(s.events, "registration-delete") + return nil +} + +type disableSSHTestConfirmer struct { + events *[]string + answer bool + calls int + labels []string +} + +func (c *disableSSHTestConfirmer) ConfirmYesNo(label string) bool { + c.calls++ + c.labels = append(c.labels, label) + recordDisableSSHEvent(c.events, "confirm") + return c.answer +} + +type disableSSHTestGater struct { + events *[]string + calls int + reasons []string + err error +} + +func (g *disableSSHTestGater) Gate(_ *terminal.Terminal, _ terminal.Confirmer, reason string, _ bool) error { + g.calls++ + g.reasons = append(g.reasons, reason) + recordDisableSSHEvent(g.events, "sudo") + return g.err +} + +type disableSSHTestTunnel struct { + events *[]string + ensureCalls int + uninstallCalls int + err error +} + +func (t *disableSSHTestTunnel) EnsureConnected(context.Context) error { + t.ensureCalls++ + recordDisableSSHEvent(t.events, "tunnel") + return t.err +} + +// Uninstall is deliberately outside register.NetBirdConnector. It makes an +// accidental concrete-type assertion or broadened dependency observable. +func (t *disableSSHTestTunnel) Uninstall() error { + t.uninstallCalls++ + recordDisableSSHEvent(t.events, "netbird-uninstall") + return nil +} + +type disableSSHTestKeyCleaner struct { + events *[]string + result KeyCleanupResult + err error + calls int +} + +func (c *disableSSHTestKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) { + c.calls++ + recordDisableSSHEvent(c.events, "cleanup") + return c.result, c.err +} + +type disableSSHRecordingClient struct { + nodev1connect.ExternalNodeServiceClient + + events *[]string + node *nodev1.ExternalNode + getErr error + + mu sync.Mutex + revokeRequests []*nodev1.RevokeNodeSSHAccessRequest + revokeErrors map[int]error + activeRevokes int + maxActiveRevokes int + + addNodeCalls int + removeNodeCalls int + closePortCalls int +} + +func (c *disableSSHRecordingClient) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { + recordDisableSSHEvent(c.events, "get-node") + if c.getErr != nil { + return nil, c.getErr + } + if req.Msg.GetExternalNodeId() != "node_123" || req.Msg.GetOrganizationId() != "org_123" { + return nil, fmt.Errorf("unexpected GetNode request: %+v", req.Msg) + } + return connect.NewResponse(&nodev1.GetNodeResponse{ExternalNode: c.node}), nil +} + +func (c *disableSSHRecordingClient) RevokeNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.RevokeNodeSSHAccessRequest]) (*connect.Response[nodev1.RevokeNodeSSHAccessResponse], error) { + c.mu.Lock() + callIndex := len(c.revokeRequests) + c.revokeRequests = append(c.revokeRequests, cloneRevokeRequest(req.Msg)) + c.activeRevokes++ + if c.activeRevokes > c.maxActiveRevokes { + c.maxActiveRevokes = c.activeRevokes + } + c.mu.Unlock() + + recordDisableSSHEvent(c.events, "revoke:"+req.Msg.GetUserId()) + time.Sleep(time.Millisecond) + + c.mu.Lock() + c.activeRevokes-- + err := c.revokeErrors[callIndex] + c.mu.Unlock() + if err != nil { + return nil, err + } + return connect.NewResponse(&nodev1.RevokeNodeSSHAccessResponse{}), nil +} + +func (c *disableSSHRecordingClient) AddNode(context.Context, *connect.Request[nodev1.AddNodeRequest]) (*connect.Response[nodev1.AddNodeResponse], error) { + c.addNodeCalls++ + recordDisableSSHEvent(c.events, "add-node") + return connect.NewResponse(&nodev1.AddNodeResponse{}), nil +} + +func (c *disableSSHRecordingClient) RemoveNode(context.Context, *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { + c.removeNodeCalls++ + recordDisableSSHEvent(c.events, "remove-node") + return connect.NewResponse(&nodev1.RemoveNodeResponse{}), nil +} + +func (c *disableSSHRecordingClient) ClosePort(context.Context, *connect.Request[nodev1.ClosePortRequest]) (*connect.Response[nodev1.ClosePortResponse], error) { + c.closePortCalls++ + recordDisableSSHEvent(c.events, "close-port") + return connect.NewResponse(&nodev1.ClosePortResponse{}), nil +} + +type disableSSHTestNodeClientFactory struct { + client nodev1connect.ExternalNodeServiceClient +} + +func (f disableSSHTestNodeClientFactory) NewNodeClient(externalnode.TokenProvider, string) nodev1connect.ExternalNodeServiceClient { + return f.client +} + +type disableSSHTestHarness struct { + events []string + store *disableSSHTestStore + registrations *disableSSHTestRegistrationStore + confirmer *disableSSHTestConfirmer + gater *disableSSHTestGater + tunnel *disableSSHTestTunnel + cleaner *disableSSHTestKeyCleaner + client *disableSSHRecordingClient + deps disableSSHDeps +} + +func newDisableSSHTestHarness(accesses ...*nodev1.SSHAccess) *disableSSHTestHarness { + h := &disableSSHTestHarness{} + h.store = &disableSSHTestStore{events: &h.events} + h.registrations = &disableSSHTestRegistrationStore{ + events: &h.events, + exists: true, + reg: ®ister.DeviceRegistration{ + ExternalNodeID: "node_123", + DisplayName: "owned-node", + OrgID: "org_123", + OrgName: "owned-org", + }, + } + h.confirmer = &disableSSHTestConfirmer{events: &h.events, answer: true} + h.gater = &disableSSHTestGater{events: &h.events} + h.tunnel = &disableSSHTestTunnel{events: &h.events} + h.cleaner = &disableSSHTestKeyCleaner{ + events: &h.events, + result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}, + } + h.client = &disableSSHRecordingClient{ + events: &h.events, + node: &nodev1.ExternalNode{ExternalNodeId: "node_123", Name: "owned-node", SshAccess: accesses}, + revokeErrors: make(map[int]error), + } + h.deps = disableSSHDeps{ + platform: &disableSSHTestPlatform{compatible: true, events: &h.events}, + confirmer: h.confirmer, + gater: h.gater, + tunnel: h.tunnel, + nodeClients: disableSSHTestNodeClientFactory{client: h.client}, + registrationStore: h.registrations, + keyCleaner: h.cleaner, + } + return h +} + +func (h *disableSSHTestHarness) run(t *testing.T, skipConfirm bool) (stdout string, stderr string, err error) { + t.Helper() + var warnings bytes.Buffer + stdout, err = captureDisableSSHStdout(t, func(term *terminal.Terminal) error { + return runDisableSSH(context.Background(), term, &warnings, h.store, h.deps, skipConfirm) + }) + return stdout, warnings.String(), err +} + +func TestNewCmdDisableSSH_CommandSurface(t *testing.T) { + cmd := NewCmdDisableSSH(terminal.New(), &disableSSHTestStore{}) + require.Equal(t, "disable-ssh", cmd.Use) + require.Equal(t, "Disable all Brev-managed SSH access on this node", cmd.Short) + require.NotNil(t, cmd.Args) + require.Contains(t, cmd.Annotations, "configuration") + require.Empty(t, cmd.Aliases) + require.NotNil(t, cmd.Flags().Lookup("approve")) +} + +func TestNewCmdDisableSSH_RejectsArguments(t *testing.T) { + h := newDisableSSHTestHarness() + cmd := newCmdDisableSSH(terminal.New(), h.store, h.deps) + cmd.SetArgs([]string{"unexpected"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "unknown command") + require.Empty(t, h.events) +} + +func TestRunDisableSSH_MissingRegistrationDoesNotAuthenticateOrCallRPC(t *testing.T) { + h := newDisableSSHTestHarness() + h.registrations.exists = false + + _, _, err := h.run(t, false) + require.EqualError(t, err, `This machine has not joined a Brev network; run "brev join" first.`) + require.Equal(t, []string{"platform", "registration-exists"}, h.events) + require.Zero(t, h.store.currentUserCalls) + require.Empty(t, h.client.revokeRequests) + require.Zero(t, h.tunnel.ensureCalls) + require.Zero(t, h.gater.calls) + require.Zero(t, h.cleaner.calls) +} + +func TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + h.confirmer.answer = false + + _, _, err := h.run(t, false) + require.NoError(t, err) + require.Equal(t, []string{"platform", "registration-exists", "registration-load", "auth", "get-node", "confirm"}, h.events) + require.Zero(t, h.gater.calls) + require.Zero(t, h.tunnel.ensureCalls) + require.Empty(t, h.client.revokeRequests) + require.Zero(t, h.cleaner.calls) +} + +func TestRunDisableSSH_ApproveSkipsConfirmationButPrintsSafetyWarning(t *testing.T) { + h := newDisableSSHTestHarness() + + _, stderr, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.confirmer.calls) + require.Contains(t, stderr, "node-wide") + require.Contains(t, stderr, "active SSH sessions are not forcibly terminated") + require.Equal(t, 1, h.cleaner.calls) +} + +func TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts(t *testing.T) { + h := newDisableSSHTestHarness( + testSSHAccess("user_1", "ubuntu", "port_1"), + testSSHAccess("user_2", "ubuntu", "port_2"), + testSSHAccess("user_3", "alice", "port_3"), + ) + + stdout, _, err := h.run(t, true) + require.NoError(t, err) + for _, text := range []string{"owned-node", "node_123", "SSH grants: 3", "Linux accounts: 2"} { + require.Contains(t, stdout, text) + } +} + +func TestRunDisableSSH_IgnoresNilAccessEntries(t *testing.T) { + h := newDisableSSHTestHarness( + testSSHAccess("user_1", "ubuntu", "port_1"), + nil, + testSSHAccess("user_2", "alice", "port_2"), + ) + + stdout, _, err := h.run(t, true) + require.NoError(t, err) + require.Contains(t, stdout, "SSH grants: 2") + require.Len(t, h.client.revokeRequests, 2) +} + +func TestRunDisableSSH_ConnectsBeforeFirstRevocation(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + + _, _, err := h.run(t, true) + require.NoError(t, err) + requireOrderedSubsequence(t, h.events, "sudo", "tunnel", "revoke:user_1", "cleanup") +} + +func TestRunDisableSSH_RevokesEveryExactTupleSequentiallyOnce(t *testing.T) { + accesses := []*nodev1.SSHAccess{ + testSSHAccess("user_1", "ubuntu", "port_1"), + testSSHAccess("user_2", "ubuntu", "port_2"), + testSSHAccess("user_3", "alice", "port_3"), + } + h := newDisableSSHTestHarness(accesses...) + + _, _, err := h.run(t, true) + require.NoError(t, err) + require.Equal(t, 1, h.client.maxActiveRevokes) + require.Len(t, h.client.revokeRequests, len(accesses)) + for i, access := range accesses { + require.Equal(t, &nodev1.RevokeNodeSSHAccessRequest{ + ExternalNodeId: "node_123", + PortId: access.GetPortId(), + UserId: access.GetUserId(), + LinuxUser: access.GetLinuxUser(), + }, h.client.revokeRequests[i]) + } +} + +func TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors(t *testing.T) { + firstErr := errors.New("first revoke failed") + middleErr := errors.New("middle revoke failed") + h := newDisableSSHTestHarness( + testSSHAccess("user_1", "ubuntu", "port_1"), + testSSHAccess("user_2", "alice", "port_2"), + testSSHAccess("user_3", "carol", "port_3"), + ) + h.client.revokeErrors[0] = firstErr + h.client.revokeErrors[1] = middleErr + + _, _, err := h.run(t, true) + require.Error(t, err) + require.ErrorIs(t, err, firstErr) + require.ErrorIs(t, err, middleErr) + require.Contains(t, err.Error(), "disable SSH backend cleanup incomplete") + for _, text := range []string{"user_1", "ubuntu", "port_1", "user_2", "alice", "port_2"} { + require.Contains(t, err.Error(), text) + } + require.Len(t, h.client.revokeRequests, 3) + require.Equal(t, []string{"revoke:user_1", "revoke:user_2", "revoke:user_3"}, filterDisableSSHEvents(h.events, "revoke:")) +} + +func TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup(t *testing.T) { + h := newDisableSSHTestHarness( + testSSHAccess("user_1", "ubuntu", "port_1"), + testSSHAccess("user_2", "alice", "port_2"), + ) + h.client.revokeErrors[0] = errors.New("revocation failed") + + _, _, err := h.run(t, true) + require.Error(t, err) + require.Len(t, h.client.revokeRequests, 2) + require.Zero(t, h.cleaner.calls) +} + +func TestRunDisableSSH_NotFoundRevocationBlocksLocalCleanup(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + h.client.revokeErrors[0] = connect.NewError(connect.CodeNotFound, errors.New("port missing")) + + _, _, err := h.run(t, true) + require.Error(t, err) + require.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) + require.Zero(t, h.cleaner.calls) +} + +func TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys(t *testing.T) { + h := newDisableSSHTestHarness() + + _, _, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.tunnel.ensureCalls) + require.Empty(t, h.client.revokeRequests) + require.Equal(t, 1, h.cleaner.calls) + requireOrderedSubsequence(t, h.events, "sudo", "cleanup") +} + +func TestRunDisableSSH_TunnelFailureStopsBeforeRevocationAndCleanup(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + tunnelErr := errors.New("tunnel unavailable") + h.tunnel.err = tunnelErr + + _, _, err := h.run(t, true) + require.ErrorIs(t, err, tunnelErr) + require.Contains(t, err.Error(), "connected Brev tunnel") + require.Empty(t, h.client.revokeRequests) + require.Zero(t, h.cleaner.calls) +} + +func TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + cleanupErr := errors.New("local cleanup failed") + h.cleaner.err = cleanupErr + + _, _, err := h.run(t, true) + require.ErrorIs(t, err, cleanupErr) + require.Contains(t, err.Error(), "disable SSH local key cleanup incomplete") + require.Equal(t, 1, h.cleaner.calls) + require.Zero(t, h.client.removeNodeCalls) + require.Zero(t, h.registrations.deleteCalls) + require.Zero(t, h.tunnel.uninstallCalls) +} + +func TestRunDisableSSH_DoesNotRemoveNodeClosePortUninstallNetBirdOrDeleteRegistration(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + + _, _, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.client.removeNodeCalls) + require.Zero(t, h.client.closePortCalls) + require.Zero(t, h.client.addNodeCalls) + require.Zero(t, h.tunnel.uninstallCalls) + require.Zero(t, h.registrations.deleteCalls) + require.Zero(t, h.registrations.saveCalls) +} + +func TestRunDisableSSH_SuccessIncludesCleanupCounts(t *testing.T) { + h := newDisableSSHTestHarness() + + stdout, _, err := h.run(t, true) + require.NoError(t, err) + require.Contains(t, stdout, "3") + require.Contains(t, stdout, "keys removed") + require.Contains(t, stdout, "2") + require.Contains(t, stdout, "accounts changed") +} + +func TestRunDisableSSH_StateMachineOrdersPreflightConfirmationAndSudo(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + + _, _, err := h.run(t, false) + require.NoError(t, err) + require.Equal(t, []string{ + "platform", + "registration-exists", + "registration-load", + "auth", + "get-node", + "confirm", + "sudo", + "tunnel", + "revoke:user_1", + "cleanup", + }, h.events) + require.Equal(t, []string{"Node-wide Brev SSH cleanup"}, h.gater.reasons) +} + +func TestRunDisableSSH_BackendNodeFailureStopsBeforeConfirmationAndMutation(t *testing.T) { + h := newDisableSSHTestHarness() + h.client.getErr = errors.New("backend unavailable") + + _, _, err := h.run(t, false) + require.Error(t, err) + require.Equal(t, []string{"platform", "registration-exists", "registration-load", "auth", "get-node"}, h.events) + require.Zero(t, h.confirmer.calls) + require.Zero(t, h.gater.calls) + require.Zero(t, h.tunnel.ensureCalls) + require.Zero(t, h.cleaner.calls) +} + +func cloneRevokeRequest(req *nodev1.RevokeNodeSSHAccessRequest) *nodev1.RevokeNodeSSHAccessRequest { + return &nodev1.RevokeNodeSSHAccessRequest{ + ExternalNodeId: req.GetExternalNodeId(), + PortId: req.GetPortId(), + UserId: req.GetUserId(), + LinuxUser: req.GetLinuxUser(), + } +} + +func testSSHAccess(userID, linuxUser, portID string) *nodev1.SSHAccess { + return &nodev1.SSHAccess{UserId: userID, LinuxUser: linuxUser, PortId: portID} +} + +func recordDisableSSHEvent(events *[]string, event string) { + if events != nil { + *events = append(*events, event) + } +} + +func filterDisableSSHEvents(events []string, prefix string) []string { + var filtered []string + for _, event := range events { + if strings.HasPrefix(event, prefix) { + filtered = append(filtered, event) + } + } + return filtered +} + +func requireOrderedSubsequence(t *testing.T, events []string, expected ...string) { + t.Helper() + next := 0 + for _, event := range events { + if next < len(expected) && event == expected[next] { + next++ + } + } + require.Equal(t, len(expected), next, "events %v do not contain ordered subsequence %v", events, expected) +} + +func captureDisableSSHStdout(t *testing.T, run func(*terminal.Terminal) error) (string, error) { + t.Helper() + reader, writer, err := os.Pipe() + require.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = writer + term := terminal.New() + os.Stdout = oldStdout + + runErr := run(term) + require.NoError(t, writer.Close()) + output, readErr := io.ReadAll(reader) + require.NoError(t, readErr) + require.NoError(t, reader.Close()) + return string(output), runErr +} From 90dbd8bc79b566d8d88aa14837d033f20d9df917 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 15:18:44 -0700 Subject: [PATCH 14/23] feat: separate network leave from SSH cleanup --- pkg/cmd/cmd.go | 2 +- pkg/cmd/cmd_test.go | 12 + pkg/cmd/deregister/deregister.go | 239 +++++---- pkg/cmd/deregister/deregister_test.go | 675 +++++++++++++++----------- 4 files changed, 514 insertions(+), 414 deletions(-) diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 33a7b68f..439faab5 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -318,7 +318,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(profile.NewCmdProfile(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(refresh.NewCmdRefresh(t, loginCmdStore)) cmd.AddCommand(register.NewCmdJoin(t, externalNodeCmdStore)) - cmd.AddCommand(deregister.NewCmdDeregister(t, externalNodeCmdStore)) + cmd.AddCommand(deregister.NewCmdLeave(t, externalNodeCmdStore)) cmd.AddCommand(upgrade.NewCmdUpgrade(t, noLoginCmdStore)) cmd.AddCommand(enablessh.NewCmdEnableSSH(t, externalNodeCmdStore)) cmd.AddCommand(disablessh.NewCmdDisableSSH(t, externalNodeCmdStore)) diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go index 51d8e1cd..367b9906 100644 --- a/pkg/cmd/cmd_test.go +++ b/pkg/cmd/cmd_test.go @@ -44,14 +44,26 @@ func TestNewBrevCommand_BYONCommandSurface(t *testing.T) { require.NoError(t, err) require.Equal(t, "disable-ssh", disableSSH.Name()) require.Empty(t, disableSSH.Aliases) + leave, _, err := root.Find([]string{"leave"}) + require.NoError(t, err) + deregister, _, err := root.Find([]string{"deregister"}) + require.NoError(t, err) + require.Equal(t, "leave", leave.Name()) + require.Equal(t, []string{"deregister"}, leave.Aliases) + require.Same(t, leave, deregister) var disableSSHCount int + var leaveCount int for _, command := range root.Commands() { if command.Name() == "disable-ssh" { disableSSHCount++ } + if command.Name() == "leave" { + leaveCount++ + } } require.Equal(t, 1, disableSSHCount) + require.Equal(t, 1, leaveCount) } func TestEmailCachingAuthStore_SaveCachesEmail(t *testing.T) { diff --git a/pkg/cmd/deregister/deregister.go b/pkg/cmd/deregister/deregister.go index efd9090b..81b05bcf 100644 --- a/pkg/cmd/deregister/deregister.go +++ b/pkg/cmd/deregister/deregister.go @@ -1,18 +1,20 @@ -// Package deregister provides the brev deregister command for device deregistration +// Package deregister provides the canonical Brev network leave command and +// its deprecated deregister alias. package deregister import ( "context" "fmt" - "os/user" + "io" + nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" - breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/externalnode" "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" @@ -20,192 +22,175 @@ import ( "github.com/spf13/cobra" ) -// DeregisterStore defines the store methods needed by the deregister command. -type DeregisterStore interface { +// LeaveStore defines the authenticated store methods needed by leave. +type LeaveStore interface { GetCurrentUser() (*entity.User, error) GetAccessToken() (string, error) } -// SSHKeyRemover removes Brev-managed SSH keys and returns the lines removed. -type SSHKeyRemover interface { - RemoveBrevKeys(u *user.User) ([]string, error) -} - -// brevSSHKeyRemover delegates to register.RemoveBrevAuthorizedKeys. -type brevSSHKeyRemover struct{} - -func (brevSSHKeyRemover) RemoveBrevKeys(u *user.User) ([]string, error) { - removed, err := register.RemoveBrevAuthorizedKeys(u) - if err != nil { - return nil, fmt.Errorf("removing brev authorized keys: %w", err) - } - return removed, nil +type netBirdUninstaller interface { + Uninstall() error } -// deregisterDeps bundles the side-effecting dependencies of runDeregister so -// they can be replaced in tests. -type deregisterDeps struct { +type leaveDeps struct { platform externalnode.PlatformChecker - prompter terminal.Selector confirmer terminal.Confirmer gater sudo.Gater - netbird register.NetBirdManager + netbird netBirdUninstaller nodeClients externalnode.NodeClientFactory registrationStore register.RegistrationStore - sshKeys SSHKeyRemover } -func defaultDeregisterDeps() deregisterDeps { - return deregisterDeps{ +func defaultLeaveDeps() leaveDeps { + return leaveDeps{ platform: register.LinuxPlatform{}, - prompter: register.TerminalPrompter{}, confirmer: register.TerminalPrompter{}, gater: sudo.Default, netbird: register.Netbird{}, nodeClients: register.DefaultNodeClientFactory{}, registrationStore: register.NewFileRegistrationStore(), - sshKeys: brevSSHKeyRemover{}, } } -var ( - deregisterLong = `Deregister your device from NVIDIA Brev +const leaveLong = `Leave the Brev network -This command removes the local registration data and uninstalls -the Brev tunnel (network agent).` +This removes the backend node, uninstalls the Brev tunnel, and deletes local +registration data. It does not revoke SSH grants or remove authorized_keys +entries; run "brev disable-ssh" first when those credentials should be removed.` - deregisterExample = ` brev deregister` -) +// NewCmdLeave creates the canonical network-membership teardown command. +func NewCmdLeave(t *terminal.Terminal, store LeaveStore) *cobra.Command { + return newCmdLeave(t, store, defaultLeaveDeps()) +} -func NewCmdDeregister(t *terminal.Terminal, store DeregisterStore) *cobra.Command { +func newCmdLeave(t *terminal.Terminal, store LeaveStore, deps leaveDeps) *cobra.Command { var approveFlag bool - cmd := &cobra.Command{ Annotations: map[string]string{"configuration": ""}, - Use: "deregister", + Use: "leave", + Aliases: []string{"deregister"}, DisableFlagsInUseLine: true, - Short: "Deregister your device from Brev", - Long: deregisterLong, - Example: deregisterExample, - RunE: func(cmd *cobra.Command, args []string) error { - return runDeregister(cmd.Context(), t, store, defaultDeregisterDeps(), approveFlag) + Short: "Leave the Brev network", + Long: leaveLong, + Example: " brev leave\n brev leave --approve", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if cmd.CalledAs() == "deregister" { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev deregister" is deprecated; use "brev leave" instead.`) + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer removes SSH keys; run "brev disable-ssh" before leaving if you want to remove Brev-managed SSH access.`) + } + return runLeave(cmd.Context(), t, cmd.ErrOrStderr(), store, deps, approveFlag) }, } - cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip confirmation prompt (assume yes)") - return cmd } -func runDeregister(ctx context.Context, t *terminal.Terminal, s DeregisterStore, deps deregisterDeps, skipConfirm bool) error { //nolint:funlen,gocyclo // deregistration flow +func runLeave( + ctx context.Context, + t *terminal.Terminal, + warnings io.Writer, + store LeaveStore, + deps leaveDeps, + skipConfirm bool, +) error { //nolint:funlen // The retry-safe teardown order is intentionally explicit. if !deps.platform.IsCompatible() { - return fmt.Errorf("brev deregister is only supported on Linux") - } - - if err := deps.gater.Gate(t, deps.confirmer, "Device deregistration", skipConfirm); err != nil { - return fmt.Errorf("sudo issue: %w", err) + return fmt.Errorf("brev leave is only supported on Linux") } reg, err := deps.registrationStore.Load() if err != nil { - return err //nolint:wrapcheck // do not present stack trace for this error + return fmt.Errorf("read joined-device registration: %w", err) } - - // Only prompt for login when there is a device to deregister. - if _, err := s.GetCurrentUser(); err != nil { + if _, err := store.GetCurrentUser(); err != nil { return breverrors.WrapAndTrace(err) } - orgName := reg.OrgName - if orgName == "" { - orgName = "(unknown)" + client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + node, missing, err := lookupJoinedNodeForLeave(ctx, client, reg) + if err != nil { + return fmt.Errorf("inspect joined node before leaving: %w", err) } - osUser, _ := user.Current() - linuxUser := "(unknown)" - if osUser != nil { - linuxUser = osUser.Username + if warnings == nil { + warnings = io.Discard + } + _, _ = fmt.Fprintln(warnings, "Leaving removes the Brev tunnel and may interrupt commands using Brev SSH. Run this locally or through out-of-band access.") + if missing { + _, _ = fmt.Fprintln(warnings, "Warning: the backend node is already absent; tagged host keys may remain on this machine.") + } else { + grantCount, accountCount := remainingSSHAccessCounts(node.GetSshAccess()) + if grantCount > 0 { + _, _ = fmt.Fprintf(warnings, "Warning: %d SSH grants across %d Linux accounts remain on this node.\n", grantCount, accountCount) + _, _ = fmt.Fprintln(warnings, `Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed.`) + } } t.Vprint("") t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint(t.White(" Deregistering your device from Brev")) + t.Vprint(t.White(" Leaving the Brev network")) t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint("") - if !skipConfirm { - t.Vprint(t.Green(" Please confirm before continuing:")) - t.Vprint("") - } - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Device:")), t.BoldBlue(reg.DisplayName+" ("+reg.ExternalNodeID+")")) - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Organization:")), t.BoldBlue(orgName+" ("+reg.OrgID+")")) - t.Vprintf(" %s %s\n", t.Green(fmt.Sprintf("%-14s", "Linux user:")), t.BoldBlue(linuxUser)) - t.Vprint("") - t.Vprint(t.Yellow(" This will:")) - t.Vprint(" 1. Remove this node from Brev") - t.Vprint(" 2. Remove Brev SSH keys from this machine (if any)") - t.Vprint(" 3. Uninstall the Brev tunnel") - t.Vprint(" 4. Delete local registration data") + t.Vprintf(" Node: %s (%s)\n", reg.DisplayName, reg.ExternalNodeID) + t.Vprintf(" Organization: %s (%s)\n", reg.OrgName, reg.OrgID) t.Vprint("") - if !skipConfirm { - confirm := deps.prompter.Select( - "Proceed with deregistration?", - []string{"Yes, proceed", "No, cancel"}, - ) - if confirm != "Yes, proceed" { - t.Vprint("Deregistration canceled.") - return nil - } + if !skipConfirm && !deps.confirmer.ConfirmYesNo("Leave the Brev network?") { + t.Vprint("Leave canceled.") + return nil + } + if err := deps.gater.Gate(t, deps.confirmer, "Leave Brev network", true); err != nil { + return fmt.Errorf("sudo issue: %w", err) } - t.Vprint(t.Yellow("[Step 1/4] Removing node from Brev...")) - client := deps.nodeClients.NewNodeClient(s, config.GlobalConfig.GetBrevPublicAPIURL()) _, err = client.RemoveNode(ctx, connect.NewRequest(&nodev1.RemoveNodeRequest{ ExternalNodeId: reg.ExternalNodeID, })) - if err != nil { - return fmt.Errorf("failed to deregister node: %w", err) + if err != nil && connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("leave Brev network: remove node: %w", err) } - t.Vprintf("%s Node removed from Brev.\n", t.Green(" ✓")) - t.Vprint("") - - t.Vprint(t.Yellow("[Step 2/4] Removing Brev SSH keys...")) - if osUser == nil { - t.Vprintf(" %s\n", t.Yellow("Skipped: could not determine current user")) - } else { - removed, kerr := deps.sshKeys.RemoveBrevKeys(osUser) - switch { - case kerr != nil: - t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove Brev SSH keys: %v", kerr))) - case len(removed) > 0: - t.Vprintf("%s Brev SSH keys removed from authorized_keys:\n", t.Green(" ✓")) - for _, key := range removed { - t.Vprintf(" - %s\n", key) - } - default: - t.Vprint(" No Brev SSH keys found in authorized_keys.") - } + if err := deps.netbird.Uninstall(); err != nil { + return fmt.Errorf("leave Brev network: uninstall tunnel: %w", err) } - t.Vprint("") - - t.Vprint(t.Yellow("[Step 3/4] Removing Brev tunnel...")) - err = deps.netbird.Uninstall() - if err != nil { - t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove Brev tunnel: %v", err))) - } else { - t.Vprintf("%s Brev tunnel removed.\n", t.Green(" ✓")) + if err := deps.registrationStore.Delete(); err != nil { + return fmt.Errorf("leave Brev network: delete local registration: %w", err) } - t.Vprint("") + t.Vprint("Left the Brev network.") + return nil +} - t.Vprint(t.Yellow("[Step 4/4] Removing registration data...")) - err = deps.registrationStore.Delete() +func lookupJoinedNodeForLeave( + ctx context.Context, + client nodev1connect.ExternalNodeServiceClient, + reg *register.DeviceRegistration, +) (*nodev1.ExternalNode, bool, error) { + resp, err := client.ListNodes(ctx, connect.NewRequest(&nodev1.ListNodesRequest{ + OrganizationId: reg.OrgID, + })) if err != nil { - t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: failed to remove local registration file: %v", err))) - t.Vprint(" You can manually remove it with: rm /etc/brev/device_registration.json") - } else { - t.Vprintf("%s Registration data removed.\n", t.Green(" ✓")) + return nil, false, fmt.Errorf("list organization nodes: %w", err) } - t.Vprintf("%s Deregistration complete.\n", t.Green(" ✓")) - t.Vprint("") + if resp == nil || resp.Msg == nil { + return nil, false, fmt.Errorf("list organization nodes: empty response") + } + for _, candidate := range resp.Msg.GetItems() { + if candidate != nil && candidate.GetExternalNodeId() == reg.ExternalNodeID { + return candidate, false, nil + } + } + if resp.Msg.GetNextPageToken() != "" { + return nil, false, fmt.Errorf("registered node was not in the returned page and node listing is incomplete") + } + return nil, true, nil +} - return nil +func remainingSSHAccessCounts(accesses []*nodev1.SSHAccess) (int, int) { + accounts := make(map[string]struct{}, len(accesses)) + grantCount := 0 + for _, access := range accesses { + if access == nil { + continue + } + grantCount++ + accounts[access.GetLinuxUser()] = struct{}{} + } + return grantCount, len(accounts) } diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index 63ff9150..68079ff7 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -1,387 +1,490 @@ package deregister import ( + "bytes" "context" - "fmt" - "net/http/httptest" - "os/user" + "errors" + "io" + "os" + "strings" "testing" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" "connectrpc.com/connect" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" "github.com/brevdev/brev-cli/pkg/cmd/register" "github.com/brevdev/brev-cli/pkg/entity" "github.com/brevdev/brev-cli/pkg/externalnode" - "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" ) -type mockDeregisterStore struct { - user *entity.User - token string - err error +type leaveTestPlatform struct { + compatible bool + events *[]string } -func (m *mockDeregisterStore) GetCurrentUser() (*entity.User, error) { - if m.err != nil { - return nil, m.err - } - return m.user, nil +func (p *leaveTestPlatform) IsCompatible() bool { + recordLeaveEvent(p.events, "platform") + return p.compatible } -func (m *mockDeregisterStore) GetAccessToken() (string, error) { return m.token, nil } - -// fakeNodeService implements the server side of ExternalNodeService for testing. -type fakeNodeService struct { - nodev1connect.UnimplementedExternalNodeServiceHandler - removeNodeFn func(*nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) +type leaveTestStore struct { + events *[]string + currentUserCalls int + currentUserErr error } -func (f *fakeNodeService) RemoveNode(_ context.Context, req *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { - resp, err := f.removeNodeFn(req.Msg) - if err != nil { - return nil, err +func (s *leaveTestStore) GetCurrentUser() (*entity.User, error) { + s.currentUserCalls++ + recordLeaveEvent(s.events, "auth") + if s.currentUserErr != nil { + return nil, s.currentUserErr } - return connect.NewResponse(resp), nil + return &entity.User{ID: "user_current"}, nil } -// mockRegistrationStore satisfies register.RegistrationStore for deregister tests. -type mockRegistrationStore struct { - reg *register.DeviceRegistration +func (*leaveTestStore) GetAccessToken() (string, error) { return "token", nil } + +type leaveTestRegistrationStore struct { + events *[]string + reg *register.DeviceRegistration + loadErr error + deleteErr error + saveCalls int + deleteCalls int } -func (m *mockRegistrationStore) Save(reg *register.DeviceRegistration) error { - m.reg = reg +func (s *leaveTestRegistrationStore) Save(*register.DeviceRegistration) error { + s.saveCalls++ + recordLeaveEvent(s.events, "registration-save") return nil } -func (m *mockRegistrationStore) Load() (*register.DeviceRegistration, error) { - if m.reg == nil { - return nil, fmt.Errorf("no registration") +func (s *leaveTestRegistrationStore) Load() (*register.DeviceRegistration, error) { + recordLeaveEvent(s.events, "registration-load") + if s.loadErr != nil { + return nil, s.loadErr } - return m.reg, nil + return s.reg, nil } -func (m *mockRegistrationStore) Delete() error { - m.reg = nil +func (s *leaveTestRegistrationStore) Delete() error { + s.deleteCalls++ + recordLeaveEvent(s.events, "registration-delete") + if s.deleteErr != nil { + return s.deleteErr + } + s.reg = nil return nil } -func (m *mockRegistrationStore) Exists() (bool, error) { - return m.reg != nil, nil -} - -// mock types for deregisterDeps interfaces - -type mockPlatform struct{ compatible bool } - -func (m mockPlatform) IsCompatible() bool { return m.compatible } +func (s *leaveTestRegistrationStore) Exists() (bool, error) { return s.reg != nil, nil } -type mockSelector struct { - fn func(label string, items []string) string +type leaveTestConfirmer struct { + events *[]string + answer bool + calls int } -func (m mockSelector) Select(label string, items []string) string { - return m.fn(label, items) +func (c *leaveTestConfirmer) ConfirmYesNo(string) bool { + c.calls++ + recordLeaveEvent(c.events, "confirm") + return c.answer } -type mockConfirmer struct{ confirm bool } +type leaveTestGater struct { + events *[]string + calls int + reasons []string + err error +} -func (m mockConfirmer) ConfirmYesNo(_ string) bool { return m.confirm } +func (g *leaveTestGater) Gate(_ *terminal.Terminal, _ terminal.Confirmer, reason string, _ bool) error { + g.calls++ + g.reasons = append(g.reasons, reason) + recordLeaveEvent(g.events, "sudo") + return g.err +} -type mockNetBirdManager struct { - called bool +type leaveTestNetBird struct { + events *[]string + calls int err error } -func (m *mockNetBirdManager) Install() error { return m.err } -func (m *mockNetBirdManager) Uninstall() error { m.called = true; return m.err } -func (m *mockNetBirdManager) EnsureConnected(context.Context) error { return m.err } +func (n *leaveTestNetBird) Uninstall() error { + n.calls++ + recordLeaveEvent(n.events, "netbird-uninstall") + return n.err +} -type mockNodeClientFactory struct { - serverURL string +type leaveRecordingClient struct { + nodev1connect.ExternalNodeServiceClient + + events *[]string + listResponse *nodev1.ListNodesResponse + listErr error + returnNilList bool + removeErr error + listRequests []*nodev1.ListNodesRequest + removeRequests []*nodev1.RemoveNodeRequest + revokeCalls int } -func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { - return register.NewNodeServiceClient(provider, m.serverURL) +func (c *leaveRecordingClient) ListNodes(_ context.Context, req *connect.Request[nodev1.ListNodesRequest]) (*connect.Response[nodev1.ListNodesResponse], error) { + recordLeaveEvent(c.events, "list-nodes") + c.listRequests = append(c.listRequests, &nodev1.ListNodesRequest{OrganizationId: req.Msg.GetOrganizationId()}) + if c.listErr != nil { + return nil, c.listErr + } + if c.returnNilList { + return nil, nil + } + return connect.NewResponse(c.listResponse), nil } -type mockSSHKeyRemover struct { - called bool - err error - removed []string +func (c *leaveRecordingClient) RemoveNode(_ context.Context, req *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { + recordLeaveEvent(c.events, "remove-node") + c.removeRequests = append(c.removeRequests, &nodev1.RemoveNodeRequest{ExternalNodeId: req.Msg.GetExternalNodeId()}) + if c.removeErr != nil { + return nil, c.removeErr + } + return connect.NewResponse(&nodev1.RemoveNodeResponse{}), nil } -func (m *mockSSHKeyRemover) RemoveBrevKeys(_ *user.User) ([]string, error) { - m.called = true - return m.removed, m.err +func (c *leaveRecordingClient) RevokeNodeSSHAccess(context.Context, *connect.Request[nodev1.RevokeNodeSSHAccessRequest]) (*connect.Response[nodev1.RevokeNodeSSHAccessResponse], error) { + c.revokeCalls++ + recordLeaveEvent(c.events, "revoke-ssh") + return connect.NewResponse(&nodev1.RevokeNodeSSHAccessResponse{}), nil } -// testDeregisterDeps returns deps with all side-effects stubbed. The -// prompter defaults to confirming all prompts. -func testDeregisterDeps(t *testing.T, svc *fakeNodeService, regStore register.RegistrationStore) (deregisterDeps, *httptest.Server) { - t.Helper() +type leaveTestNodeClientFactory struct { + client nodev1connect.ExternalNodeServiceClient +} - _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) - server := httptest.NewServer(handler) - - return deregisterDeps{ - platform: mockPlatform{compatible: true}, - prompter: mockSelector{fn: func(_ string, items []string) string { - // Default: pick first item (Yes, ...) - if len(items) > 0 { - return items[0] - } - return "" - }}, - confirmer: mockConfirmer{confirm: true}, - gater: sudo.CachedGater{}, - netbird: &mockNetBirdManager{}, - nodeClients: mockNodeClientFactory{serverURL: server.URL}, - registrationStore: regStore, - sshKeys: &mockSSHKeyRemover{}, - }, server -} - -func Test_runDeregister_HappyPath(t *testing.T) { - regStore := &mockRegistrationStore{ - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "unode_abc", - DisplayName: "My Spark", - OrgID: "org_123", - DeviceID: "dev-uuid", - }, - } +func (f leaveTestNodeClientFactory) NewNodeClient(externalnode.TokenProvider, string) nodev1connect.ExternalNodeServiceClient { + return f.client +} - store := &mockDeregisterStore{ - user: &entity.User{ID: "user_1"}, +type leaveTestHarness struct { + events []string + store *leaveTestStore + registrations *leaveTestRegistrationStore + confirmer *leaveTestConfirmer + gater *leaveTestGater + netbird *leaveTestNetBird + client *leaveRecordingClient + deps leaveDeps +} - token: "tok", +func newLeaveTestHarness() *leaveTestHarness { + h := &leaveTestHarness{} + reg := ®ister.DeviceRegistration{ + ExternalNodeID: "node_123", + DisplayName: "owned-node", + OrgID: "org_123", + OrgName: "owned-org", } - - var gotNodeID string - svc := &fakeNodeService{ - removeNodeFn: func(req *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - gotNodeID = req.GetExternalNodeId() - return &nodev1.RemoveNodeResponse{}, nil - }, + node := &nodev1.ExternalNode{ExternalNodeId: reg.ExternalNodeID, Name: reg.DisplayName} + h.store = &leaveTestStore{events: &h.events} + h.registrations = &leaveTestRegistrationStore{events: &h.events, reg: reg} + h.confirmer = &leaveTestConfirmer{events: &h.events, answer: true} + h.gater = &leaveTestGater{events: &h.events} + h.netbird = &leaveTestNetBird{events: &h.events} + h.client = &leaveRecordingClient{ + events: &h.events, + listResponse: &nodev1.ListNodesResponse{Items: []*nodev1.ExternalNode{node}}, + } + h.deps = leaveDeps{ + platform: &leaveTestPlatform{compatible: true, events: &h.events}, + confirmer: h.confirmer, + gater: h.gater, + netbird: h.netbird, + nodeClients: leaveTestNodeClientFactory{client: h.client}, + registrationStore: h.registrations, } + return h +} - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() +func (h *leaveTestHarness) run(t *testing.T, approve bool) (stdout string, stderr string, err error) { + t.Helper() + var warnings bytes.Buffer + stdout, err = captureLeaveStdout(t, func(term *terminal.Terminal) error { + return runLeave(context.Background(), term, &warnings, h.store, h.deps, approve) + }) + return stdout, warnings.String(), err +} - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err != nil { - t.Fatalf("runDeregister failed: %v", err) - } +func TestNewCmdLeave_CommandSurface(t *testing.T) { + cmd := NewCmdLeave(terminal.New(), &leaveTestStore{}) + require.Equal(t, "leave", cmd.Use) + require.Equal(t, []string{"deregister"}, cmd.Aliases) + require.NotNil(t, cmd.Args) + require.Contains(t, cmd.Annotations, "configuration") + require.NotNil(t, cmd.Flags().Lookup("approve")) +} - if gotNodeID != "unode_abc" { - t.Errorf("expected node ID unode_abc, got %s", gotNodeID) - } +func TestNewCmdLeave_DeregisterAliasWarnsOnExecution(t *testing.T) { + h := newLeaveTestHarness() + var stderr bytes.Buffer + _, err := captureLeaveStdout(t, func(term *terminal.Terminal) error { + root := &cobra.Command{Use: "brev"} + root.AddCommand(newCmdLeave(term, h.store, h.deps)) + root.SetArgs([]string{"deregister", "--approve"}) + root.SetOut(io.Discard) + root.SetErr(&stderr) + return root.Execute() + }) + require.NoError(t, err) + require.True(t, strings.HasPrefix(stderr.String(), "Warning: \"brev deregister\" is deprecated; use \"brev leave\" instead.\n"+ + "This command no longer removes SSH keys; run \"brev disable-ssh\" before leaving if you want to remove Brev-managed SSH access.\n")) +} - // Registration should be deleted - exists, err := regStore.Exists() - if err != nil { - t.Fatalf("Exists error: %v", err) - } - if exists { - t.Error("expected registration to be deleted after deregister") - } +func TestNewCmdLeave_HelpDoesNotWarn(t *testing.T) { + h := newLeaveTestHarness() + var stderr bytes.Buffer + root := &cobra.Command{Use: "brev"} + root.AddCommand(newCmdLeave(terminal.New(), h.store, h.deps)) + root.SetArgs([]string{"deregister", "--help"}) + root.SetOut(io.Discard) + root.SetErr(&stderr) + + require.NoError(t, root.Execute()) + require.NotContains(t, stderr.String(), "deprecated") + require.Empty(t, h.events) } -func Test_runDeregister_UserCancels(t *testing.T) { - regStore := &mockRegistrationStore{ - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "unode_abc", - DisplayName: "My Spark", - OrgID: "org_123", - }, - } +func TestNewCmdLeave_CanonicalInvocationDoesNotWarnAboutDeprecation(t *testing.T) { + h := newLeaveTestHarness() + var stderr bytes.Buffer + _, err := captureLeaveStdout(t, func(term *terminal.Terminal) error { + root := &cobra.Command{Use: "brev"} + root.AddCommand(newCmdLeave(term, h.store, h.deps)) + root.SetArgs([]string{"leave", "--approve"}) + root.SetOut(io.Discard) + root.SetErr(&stderr) + return root.Execute() + }) + require.NoError(t, err) + require.NotContains(t, stderr.String(), "deprecated") + require.Contains(t, stderr.String(), "may interrupt commands using Brev SSH") +} - store := &mockDeregisterStore{ - user: &entity.User{ID: "user_1"}, +func TestNewCmdLeave_RejectsArguments(t *testing.T) { + for _, name := range []string{"leave", "deregister"} { + t.Run(name, func(t *testing.T) { + h := newLeaveTestHarness() + root := &cobra.Command{Use: "brev"} + root.AddCommand(newCmdLeave(terminal.New(), h.store, h.deps)) + root.SetArgs([]string{name, "unexpected"}) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + require.Error(t, root.Execute()) + require.Empty(t, h.events) + }) + } +} - token: "tok", +func TestRunLeave_RemainingGrantsWarnButDoNotBlock(t *testing.T) { + h := newLeaveTestHarness() + h.client.listResponse.Items[0].SshAccess = []*nodev1.SSHAccess{ + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, + nil, + {UserId: "user_2", LinuxUser: "ubuntu", PortId: "port_2"}, + {UserId: "user_3", LinuxUser: "alice", PortId: "port_3"}, } - svc := &fakeNodeService{} - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() + _, stderr, err := h.run(t, false) + require.NoError(t, err) + require.Contains(t, stderr, "3 SSH grants across 2 Linux accounts") + require.Contains(t, stderr, `Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed.`) + require.Len(t, h.client.removeRequests, 1) +} - deps.prompter = mockSelector{fn: func(_ string, _ []string) string { - return "No, cancel" - }} +func TestRunLeave_ApproveSkipsConfirmationButNotWarnings(t *testing.T) { + h := newLeaveTestHarness() + _, stderr, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.confirmer.calls) + require.Contains(t, stderr, "may interrupt commands using Brev SSH") + require.Equal(t, 1, h.gater.calls) +} - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err != nil { - t.Fatalf("expected nil error on cancel, got: %v", err) - } +func TestRunLeave_CancelStopsBeforeSudoAndMutation(t *testing.T) { + h := newLeaveTestHarness() + h.confirmer.answer = false + + _, _, err := h.run(t, false) + require.NoError(t, err) + require.Equal(t, []string{"platform", "registration-load", "auth", "list-nodes", "confirm"}, h.events) + require.Zero(t, h.gater.calls) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) +} - // Registration should still exist - exists, err := regStore.Exists() - if err != nil { - t.Fatalf("Exists error: %v", err) - } - if !exists { - t.Error("registration should still exist after cancel") - } +func TestRunLeave_OrderIsRemoveNodeUninstallDeleteRegistration(t *testing.T) { + h := newLeaveTestHarness() + + stdout, _, err := h.run(t, false) + require.NoError(t, err) + require.Equal(t, []string{ + "platform", "registration-load", "auth", "list-nodes", "confirm", "sudo", + "remove-node", "netbird-uninstall", "registration-delete", + }, h.events) + require.Equal(t, []string{"Leave Brev network"}, h.gater.reasons) + require.Equal(t, []*nodev1.ListNodesRequest{{OrganizationId: "org_123"}}, h.client.listRequests) + require.Equal(t, []*nodev1.RemoveNodeRequest{{ExternalNodeId: "node_123"}}, h.client.removeRequests) + require.Contains(t, stdout, "Left the Brev network.") } -func Test_runDeregister_NotRegistered(t *testing.T) { - regStore := &mockRegistrationStore{} +func TestRunLeave_CompleteNodeListWithoutRegisteredIDAllowsAuthoritativeRemoveRetry(t *testing.T) { + h := newLeaveTestHarness() + h.client.listResponse = &nodev1.ListNodesResponse{Items: []*nodev1.ExternalNode{{ExternalNodeId: "other"}}} - store := &mockDeregisterStore{ - user: &entity.User{ID: "user_1"}, + _, stderr, err := h.run(t, true) + require.NoError(t, err) + require.Contains(t, stderr, "backend node is already absent") + require.Contains(t, stderr, "tagged host keys may remain") + require.Len(t, h.client.removeRequests, 1) +} - token: "tok", +func TestRunLeave_ListPermissionDeniedStopsBeforeConfirmationAndMutation(t *testing.T) { + h := newLeaveTestHarness() + h.client.listErr = connect.NewError(connect.CodePermissionDenied, errors.New("denied")) + assertLeaveLookupFailureStopsMutation(t, h) +} + +func TestRunLeave_RegisteredIDAbsentFromIncompleteListStopsBeforeMutation(t *testing.T) { + h := newLeaveTestHarness() + h.client.listResponse = &nodev1.ListNodesResponse{ + Items: []*nodev1.ExternalNode{{ExternalNodeId: "other"}}, + NextPageToken: "next", } + assertLeaveLookupFailureStopsMutation(t, h) +} - svc := &fakeNodeService{} - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() +func TestRunLeave_OtherLookupFailureStopsBeforeConfirmationAndMutation(t *testing.T) { + h := newLeaveTestHarness() + h.client.listErr = errors.New("backend unavailable") + assertLeaveLookupFailureStopsMutation(t, h) +} - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err == nil { - t.Fatal("expected error when not registered") - } +func TestRunLeave_EmptyLookupResponseStopsBeforeConfirmationAndMutation(t *testing.T) { + h := newLeaveTestHarness() + h.client.returnNilList = true + assertLeaveLookupFailureStopsMutation(t, h) } -func Test_runDeregister_RemoveNodeFails(t *testing.T) { - regStore := &mockRegistrationStore{ - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "unode_abc", - DisplayName: "My Spark", - OrgID: "org_123", - }, - } +func TestRunLeave_EmptyLookupMessageStopsBeforeConfirmationAndMutation(t *testing.T) { + h := newLeaveTestHarness() + h.client.listResponse = nil + assertLeaveLookupFailureStopsMutation(t, h) +} - store := &mockDeregisterStore{ - user: &entity.User{ID: "user_1"}, +func TestRunLeave_RemoveNodeNotFoundIsAccepted(t *testing.T) { + h := newLeaveTestHarness() + h.client.removeErr = connect.NewError(connect.CodeNotFound, errors.New("already absent")) - token: "tok", - } + stdout, _, err := h.run(t, true) + require.NoError(t, err) + require.Equal(t, 1, h.netbird.calls) + require.Equal(t, 1, h.registrations.deleteCalls) + require.Contains(t, stdout, "Left the Brev network.") +} - svc := &fakeNodeService{ - removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - return nil, connect.NewError(connect.CodeInternal, nil) - }, - } +func TestRunLeave_RemoveNodeFailureStopsLocalTeardown(t *testing.T) { + h := newLeaveTestHarness() + removeErr := errors.New("remove failed") + h.client.removeErr = connect.NewError(connect.CodeInternal, removeErr) - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, removeErr) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") +} - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err == nil { - t.Fatal("expected error when RemoveNode fails") - } +func TestRunLeave_NetBirdFailureReturnsErrorAndRetainsRegistration(t *testing.T) { + h := newLeaveTestHarness() + netbirdErr := errors.New("uninstall failed") + h.netbird.err = netbirdErr - // Registration should still exist (server-side removal failed) - exists, err := regStore.Exists() - if err != nil { - t.Fatalf("Exists error: %v", err) - } - if !exists { - t.Error("registration should still exist when RemoveNode fails") - } + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, netbirdErr) + require.Zero(t, h.registrations.deleteCalls) + require.NotNil(t, h.registrations.reg) + require.NotContains(t, stdout, "Left the Brev network.") } -func Test_runDeregister_AlwaysUninstallsNetbird(t *testing.T) { - regStore := &mockRegistrationStore{ - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "unode_abc", - DisplayName: "My Spark", - OrgID: "org_123", - }, - } +func TestRunLeave_RegistrationDeleteFailureReturnsErrorAndNoSuccess(t *testing.T) { + h := newLeaveTestHarness() + deleteErr := errors.New("delete failed") + h.registrations.deleteErr = deleteErr - store := &mockDeregisterStore{ - user: &entity.User{ID: "user_1"}, + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, deleteErr) + require.Equal(t, 1, h.registrations.deleteCalls) + require.NotNil(t, h.registrations.reg) + require.NotContains(t, stdout, "Left the Brev network.") +} - token: "tok", - } +func TestRunLeave_NeverRevokesSSHOrSavesRegistration(t *testing.T) { + h := newLeaveTestHarness() + h.client.listResponse.Items[0].SshAccess = []*nodev1.SSHAccess{{UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}} - svc := &fakeNodeService{ - removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - return &nodev1.RemoveNodeResponse{}, nil - }, - } + _, _, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.client.revokeCalls) + require.Zero(t, h.registrations.saveCalls) + require.NotContains(t, h.events, "revoke-ssh") +} - netbird := &mockNetBirdManager{} - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() - deps.netbird = netbird +func TestRunLeave_RegistrationLoadFailureDoesNotAuthenticate(t *testing.T) { + h := newLeaveTestHarness() + h.registrations.loadErr = errors.New("registration missing") - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err != nil { - t.Fatalf("runDeregister failed: %v", err) - } + _, _, err := h.run(t, false) + require.Error(t, err) + require.Equal(t, []string{"platform", "registration-load"}, h.events) + require.Zero(t, h.store.currentUserCalls) +} - if !netbird.called { - t.Error("expected Brev tunnel uninstall to always be called during deregistration") - } +func assertLeaveLookupFailureStopsMutation(t *testing.T, h *leaveTestHarness) { + t.Helper() + _, _, err := h.run(t, false) + require.Error(t, err) + require.Contains(t, err.Error(), "inspect joined node before leaving") + require.Equal(t, []string{"platform", "registration-load", "auth", "list-nodes"}, h.events) + require.Zero(t, h.confirmer.calls) + require.Zero(t, h.gater.calls) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) } -func Test_runDeregister_RemoveBrevKeysHandling(t *testing.T) { - tests := []struct { - name string - sshKeys *mockSSHKeyRemover - wantCalled bool - }{ - {"CallsRemoveBrevKeys", &mockSSHKeyRemover{}, true}, - {"FailureIsNonFatal", &mockSSHKeyRemover{err: fmt.Errorf("permission denied")}, true}, +func recordLeaveEvent(events *[]string, event string) { + if events != nil { + *events = append(*events, event) } +} - for _, tt := range tests { - t.Run(tt.name, func(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"}, - - token: "tok", - } - - svc := &fakeNodeService{ - removeNodeFn: func(_ *nodev1.RemoveNodeRequest) (*nodev1.RemoveNodeResponse, error) { - return &nodev1.RemoveNodeResponse{}, nil - }, - } - - deps, server := testDeregisterDeps(t, svc, regStore) - defer server.Close() - deps.sshKeys = tt.sshKeys - - term := terminal.New() - err := runDeregister(context.Background(), term, store, deps, false) - if err != nil { - t.Fatalf("runDeregister failed: %v", err) - } - - if tt.sshKeys.called != tt.wantCalled { - t.Errorf("removeBrevKeys called = %v, want %v", tt.sshKeys.called, tt.wantCalled) - } - - // Registration should be cleaned up regardless of SSH key result. - exists, err := regStore.Exists() - if err != nil { - t.Fatalf("Exists error: %v", err) - } - if exists { - t.Error("expected registration to be deleted") - } - }) - } +func captureLeaveStdout(t *testing.T, run func(*terminal.Terminal) error) (string, error) { + t.Helper() + reader, writer, err := os.Pipe() + require.NoError(t, err) + oldStdout := os.Stdout + os.Stdout = writer + term := terminal.New() + os.Stdout = oldStdout + + runErr := run(term) + require.NoError(t, writer.Close()) + output, readErr := io.ReadAll(reader) + require.NoError(t, readErr) + require.NoError(t, reader.Close()) + return string(output), runErr } From c43e5f9b5518639e853fd87ce38918c36e76e854 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 15:29:17 -0700 Subject: [PATCH 15/23] fix: preserve BYON command source compatibility --- pkg/cmd/deregister/deregister.go | 13 ++++ pkg/cmd/deregister/deregister_test.go | 103 ++++++++++++++++++++++---- pkg/cmd/register/register.go | 8 ++ pkg/cmd/register/register_test.go | 7 ++ 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/deregister/deregister.go b/pkg/cmd/deregister/deregister.go index 81b05bcf..0a58ce6c 100644 --- a/pkg/cmd/deregister/deregister.go +++ b/pkg/cmd/deregister/deregister.go @@ -28,6 +28,11 @@ type LeaveStore interface { GetAccessToken() (string, error) } +// DeregisterStore is retained for source compatibility. +// +// Deprecated: use LeaveStore. +type DeregisterStore = LeaveStore + type netBirdUninstaller interface { Uninstall() error } @@ -63,6 +68,14 @@ func NewCmdLeave(t *terminal.Terminal, store LeaveStore) *cobra.Command { return newCmdLeave(t, store, defaultLeaveDeps()) } +// NewCmdDeregister is retained for source compatibility. It returns the +// canonical leave command with deregister as its deprecated alias. +// +// Deprecated: use NewCmdLeave. +func NewCmdDeregister(t *terminal.Terminal, store DeregisterStore) *cobra.Command { + return NewCmdLeave(t, store) +} + func newCmdLeave(t *terminal.Terminal, store LeaveStore, deps leaveDeps) *cobra.Command { var approveFlag bool cmd := &cobra.Command{ diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index 68079ff7..45d1a9e6 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -227,6 +227,14 @@ func TestNewCmdLeave_CommandSurface(t *testing.T) { require.NotNil(t, cmd.Flags().Lookup("approve")) } +func TestNewCmdDeregister_DeprecatedSourceCompatibility(t *testing.T) { + var store DeregisterStore = &leaveTestStore{} + cmd := NewCmdDeregister(terminal.New(), store) + + require.Equal(t, "leave", cmd.Name()) + require.Equal(t, []string{"deregister"}, cmd.Aliases) +} + func TestNewCmdLeave_DeregisterAliasWarnsOnExecution(t *testing.T) { h := newLeaveTestHarness() var stderr bytes.Buffer @@ -244,17 +252,21 @@ func TestNewCmdLeave_DeregisterAliasWarnsOnExecution(t *testing.T) { } func TestNewCmdLeave_HelpDoesNotWarn(t *testing.T) { - h := newLeaveTestHarness() - var stderr bytes.Buffer - root := &cobra.Command{Use: "brev"} - root.AddCommand(newCmdLeave(terminal.New(), h.store, h.deps)) - root.SetArgs([]string{"deregister", "--help"}) - root.SetOut(io.Discard) - root.SetErr(&stderr) + for _, name := range []string{"leave", "deregister"} { + t.Run(name, func(t *testing.T) { + h := newLeaveTestHarness() + var stderr bytes.Buffer + root := &cobra.Command{Use: "brev"} + root.AddCommand(newCmdLeave(terminal.New(), h.store, h.deps)) + root.SetArgs([]string{name, "--help"}) + root.SetOut(io.Discard) + root.SetErr(&stderr) - require.NoError(t, root.Execute()) - require.NotContains(t, stderr.String(), "deprecated") - require.Empty(t, h.events) + require.NoError(t, root.Execute()) + require.NotContains(t, stderr.String(), "deprecated") + require.Empty(t, h.events) + }) + } } func TestNewCmdLeave_CanonicalInvocationDoesNotWarnAboutDeprecation(t *testing.T) { @@ -306,10 +318,16 @@ func TestRunLeave_RemainingGrantsWarnButDoNotBlock(t *testing.T) { func TestRunLeave_ApproveSkipsConfirmationButNotWarnings(t *testing.T) { h := newLeaveTestHarness() + h.client.listResponse.Items[0].SshAccess = []*nodev1.SSHAccess{ + {UserId: "user_1", LinuxUser: "ubuntu", PortId: "port_1"}, + {UserId: "user_2", LinuxUser: "alice", PortId: "port_2"}, + } _, stderr, err := h.run(t, true) require.NoError(t, err) require.Zero(t, h.confirmer.calls) require.Contains(t, stderr, "may interrupt commands using Brev SSH") + require.Contains(t, stderr, "2 SSH grants across 2 Linux accounts") + require.Contains(t, stderr, `run "brev disable-ssh" first`) require.Equal(t, 1, h.gater.calls) } @@ -317,13 +335,63 @@ func TestRunLeave_CancelStopsBeforeSudoAndMutation(t *testing.T) { h := newLeaveTestHarness() h.confirmer.answer = false - _, _, err := h.run(t, false) + stdout, _, err := h.run(t, false) require.NoError(t, err) require.Equal(t, []string{"platform", "registration-load", "auth", "list-nodes", "confirm"}, h.events) require.Zero(t, h.gater.calls) require.Empty(t, h.client.removeRequests) require.Zero(t, h.netbird.calls) require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") +} + +func TestRunLeave_IncompatiblePlatformStopsBeforeLoadOrMutation(t *testing.T) { + h := newLeaveTestHarness() + h.deps.platform = &leaveTestPlatform{compatible: false, events: &h.events} + + stdout, _, err := h.run(t, false) + require.EqualError(t, err, "brev leave is only supported on Linux") + require.Equal(t, []string{"platform"}, h.events) + require.NotNil(t, h.registrations.reg) + require.Zero(t, h.confirmer.calls) + require.Zero(t, h.gater.calls) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") +} + +func TestRunLeave_AuthenticationFailureStopsBeforeLookupOrMutation(t *testing.T) { + h := newLeaveTestHarness() + authErr := errors.New("authentication failed") + h.store.currentUserErr = authErr + + stdout, _, err := h.run(t, false) + require.ErrorIs(t, err, authErr) + require.Equal(t, []string{"platform", "registration-load", "auth"}, h.events) + require.NotNil(t, h.registrations.reg) + require.Empty(t, h.client.listRequests) + require.Zero(t, h.confirmer.calls) + require.Zero(t, h.gater.calls) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") +} + +func TestRunLeave_SudoFailureStopsBeforeAuthoritativeMutation(t *testing.T) { + h := newLeaveTestHarness() + sudoErr := errors.New("sudo unavailable") + h.gater.err = sudoErr + + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, sudoErr) + require.Equal(t, []string{"platform", "registration-load", "auth", "list-nodes", "sudo"}, h.events) + require.NotNil(t, h.registrations.reg) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") } func TestRunLeave_OrderIsRemoveNodeUninstallDeleteRegistration(t *testing.T) { @@ -343,7 +411,7 @@ func TestRunLeave_OrderIsRemoveNodeUninstallDeleteRegistration(t *testing.T) { func TestRunLeave_CompleteNodeListWithoutRegisteredIDAllowsAuthoritativeRemoveRetry(t *testing.T) { h := newLeaveTestHarness() - h.client.listResponse = &nodev1.ListNodesResponse{Items: []*nodev1.ExternalNode{{ExternalNodeId: "other"}}} + h.client.listResponse = &nodev1.ListNodesResponse{Items: []*nodev1.ExternalNode{nil, {ExternalNodeId: "other"}}} _, stderr, err := h.run(t, true) require.NoError(t, err) @@ -447,15 +515,20 @@ func TestRunLeave_RegistrationLoadFailureDoesNotAuthenticate(t *testing.T) { h := newLeaveTestHarness() h.registrations.loadErr = errors.New("registration missing") - _, _, err := h.run(t, false) + stdout, _, err := h.run(t, false) require.Error(t, err) require.Equal(t, []string{"platform", "registration-load"}, h.events) require.Zero(t, h.store.currentUserCalls) + require.NotNil(t, h.registrations.reg) + require.Empty(t, h.client.removeRequests) + require.Zero(t, h.netbird.calls) + require.Zero(t, h.registrations.deleteCalls) + require.NotContains(t, stdout, "Left the Brev network.") } func assertLeaveLookupFailureStopsMutation(t *testing.T, h *leaveTestHarness) { t.Helper() - _, _, err := h.run(t, false) + stdout, _, err := h.run(t, false) require.Error(t, err) require.Contains(t, err.Error(), "inspect joined node before leaving") require.Equal(t, []string{"platform", "registration-load", "auth", "list-nodes"}, h.events) @@ -464,6 +537,8 @@ func assertLeaveLookupFailureStopsMutation(t *testing.T, h *leaveTestHarness) { require.Empty(t, h.client.removeRequests) require.Zero(t, h.netbird.calls) require.Zero(t, h.registrations.deleteCalls) + require.NotNil(t, h.registrations.reg) + require.NotContains(t, stdout, "Left the Brev network.") } func recordLeaveEvent(events *[]string, event string) { diff --git a/pkg/cmd/register/register.go b/pkg/cmd/register/register.go index 665bd9ce..424c5248 100644 --- a/pkg/cmd/register/register.go +++ b/pkg/cmd/register/register.go @@ -103,6 +103,14 @@ func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command { return newCmdJoin(t, store, defaultJoinDeps) } +// NewCmdRegister is retained for source compatibility. It returns the +// canonical join command with register as its deprecated alias. +// +// Deprecated: use NewCmdJoin. +func NewCmdRegister(t *terminal.Terminal, store RegisterStore) *cobra.Command { + return NewCmdJoin(t, store) +} + func newCmdJoin(t *terminal.Terminal, store RegisterStore, depsFactory func() joinDeps) *cobra.Command { var orgFlag string var nameFlag string diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 6664bfca..79cd1ac5 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -52,6 +52,13 @@ func TestNewCmdJoin_CommandSurface(t *testing.T) { require.True(t, cmd.Flags().Lookup("ssh-port").Hidden) } +func TestNewCmdRegister_DeprecatedSourceCompatibility(t *testing.T) { + cmd := NewCmdRegister(terminal.New(), panicRegisterStore{}) + + require.Equal(t, "join", cmd.Name()) + require.Equal(t, []string{"register"}, cmd.Aliases) +} + func TestNewCmdJoin_RegisterAliasWarnsOnExecution(t *testing.T) { cmd := NewCmdJoin(terminal.New(), panicRegisterStore{}) root := &cobra.Command{Use: "brev", SilenceUsage: true} From f3aafe8e573bcc38f825f5199e8d829a29a4f14e Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 15:36:03 -0700 Subject: [PATCH 16/23] docs: explain explicit BYON network and SSH flows --- .agents/skills/brev-cli/SKILL.md | 27 ++++++ .agents/skills/brev-cli/reference/commands.md | 84 +++++++++++++++++++ CHANGELOG.md | 14 ++++ README.md | 2 + docs/BYON.md | 69 +++++++++++++++ 5 files changed, 196 insertions(+) create mode 100644 docs/BYON.md diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index f054e452..cfb30305 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -202,6 +202,33 @@ brev ls --json | jq -r '.workspaces[].name' brev ls nodes --json | jq -r '.[] | select(.status=="Connected") | .name' ``` +### BYON Network and SSH + +For a machine you bring to Brev, network membership and SSH credentials are +separate operations: + +```bash +# Join only the organization's Brev/NetBird network. +brev join + +# Optionally enable access for yourself, then grant a collaborator. +brev enable-ssh +brev grant-ssh + +# Retire the node completely. +brev disable-ssh +brev leave +``` + +`brev register` and `brev deregister` are deprecated aliases for `join` and +`leave`; they warn when executed. `enable-ssh` requires an existing join and +can reconnect its tunnel, but never joins a network. Use `grant-ssh` and +`revoke-ssh` for individual collaborators. `disable-ssh` removes all +Brev-managed SSH access across the node without closing ports, stopping `sshd`, +ending active sessions, or leaving the network. `leave` removes membership but +leaves Brev-managed keys on the host; run `disable-ssh` first when removing +those keys is intended. + ### Instance Management ```bash # List instances diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index fe91cbbd..18d065be 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -497,6 +497,90 @@ Generate an invite link. brev invite ``` +## BYON Network and SSH Commands + +These commands apply to a machine brought into a Brev organization. Network +membership and Brev-managed SSH credentials are separate. + +### Canonical workflows + +```bash +# Join networking only. Then optionally enable your SSH access and grant a collaborator. +brev join +brev enable-ssh +brev grant-ssh + +# Remove Brev-managed SSH credentials before retiring network membership. +brev disable-ssh +brev leave +``` + +### brev join / brev register + +Join a device to the organization's Brev/NetBird network. + +```bash +brev join [--name --org ] [--approve] +``` + +`join` establishes membership only: it does not enable SSH or allocate an SSH +port. `register` is a deprecated alias that warns on execution. The old +`--ssh-port` flag is no longer supported; migrate scripts to `brev join` and +then `brev enable-ssh` on the joined machine. + +### brev enable-ssh + +Enable Brev-managed SSH for the invoking Brev user on the joined node. + +```bash +brev enable-ssh +``` + +This requires an existing `join`. It confirms the existing Brev tunnel and can +reconnect it when disconnected, but it does not add a node, select an +organization, save a registration, or join a network. + +### brev grant-ssh / brev revoke-ssh + +Manage an individual collaborator's SSH access tuple on a node. + +```bash +brev grant-ssh +brev revoke-ssh +``` + +Use these commands for collaborator access rather than treating `enable-ssh` +or `disable-ssh` as collaborator-management commands. + +### brev disable-ssh + +Remove all Brev-managed SSH credentials from the joined node. + +```bash +brev disable-ssh [--approve] +``` + +This node-wide operation revokes each exact active backend access tuple, then +runs a privileged root sweep to remove Brev-tagged local keys. It leaves +existing ports allocated, leaves `sshd` running, does not forcibly terminate +active SSH sessions, and does not remove membership or the backend node. + +### brev leave / brev deregister + +Remove Brev network membership from the device. + +```bash +brev leave [--approve] +``` + +`leave` removes the backend node, VPN route, and local registration. It does +not revoke grants or remove host keys from `authorized_keys`; run +`brev disable-ssh` first for complete retirement. `deregister` is a deprecated +alias that warns on execution. + +`leave` continues to uninstall NetBird even if it was installed before Brev. +Install-ownership tracking is a follow-up, so ensure that removal is intended. + ## Configuration Commands ### brev login / brev logout diff --git a/CHANGELOG.md b/CHANGELOG.md index 48648054..733e1570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added [WIP] Add tailscale vpn client embedded with Brev. + +- `brev join` for Brev/NetBird network membership, `brev leave` for membership teardown, and node-wide `brev disable-ssh`. + +### Changed + +- `brev join` no longer enables SSH. `brev enable-ssh` requires an existing joined membership and reconnects its tunnel when needed. + +### Deprecated + +- `brev register` and `brev deregister` remain compatibility aliases for `join` and `leave`, and warn on stderr when executed. + +### Migration + +- Scripts using `--ssh-port` must run `brev join` followed by `brev enable-ssh`. diff --git a/README.md b/README.md index f625c153..7eda82f7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ brev ls https://docs.nvidia.com/brev/latest/ +[Bring Your Own Node (BYON) network and SSH workflows](docs/BYON.md) + --- ## AI Agent Integration diff --git a/docs/BYON.md b/docs/BYON.md new file mode 100644 index 00000000..9c6f9cc2 --- /dev/null +++ b/docs/BYON.md @@ -0,0 +1,69 @@ +# Bring Your Own Node (BYON) + +Brev separates network membership from Brev-managed SSH credentials on a +machine you bring to your organization. + +## Join networking only + +```bash +brev join +``` + +`brev join` establishes this machine's Brev/NetBird organization membership. +It does not enable SSH or create an SSH port. Use `brev register` only for +compatibility with existing automation: it is a deprecated alias for `join` and +prints a warning when executed. + +Scripts that used `--ssh-port` must migrate to two commands: + +```bash +brev join +brev enable-ssh +``` + +## Enable and grant SSH + +After joining, enable Brev-managed SSH for the invoking Brev user: + +```bash +brev enable-ssh +``` + +`enable-ssh` requires a prior join. It confirms the existing Brev tunnel and +can reconnect it when it is disconnected; it never joins a network or creates +membership. It then enables the invoking user's access on the joined node. + +Grant and revoke collaborator access separately: + +```bash +brev grant-ssh +brev revoke-ssh +``` + +These commands manage individual collaborator access tuples. They are not part +of `join`, `enable-ssh`, or the node-wide cleanup command. + +## Retire a node completely + +For a complete retirement, remove Brev-managed SSH credentials before leaving +the network: + +```bash +brev disable-ssh +brev leave +``` + +`disable-ssh` is node-wide. It revokes each exact active Brev SSH access tuple, +then uses a privileged root sweep to remove Brev-tagged keys from local accounts. +It leaves existing ports allocated, leaves `sshd` running, does not forcibly +terminate active SSH sessions, and does not change network membership. + +`leave` removes the backend node, Brev VPN route, and local registration. It +deliberately does not revoke SSH grants or remove keys already stored in +`authorized_keys`; use `disable-ssh` first when those credentials should be +removed. `brev deregister` is a deprecated alias for `leave` and warns when +executed. + +`leave` preserves the existing behavior of uninstalling NetBird even when +NetBird was installed before Brev. Tracking whether Brev owns that installation +is a follow-up improvement, so use `leave` only when that removal is intended. From f15ffa3d0fd92fccdf1b0fd19f8cd6509f397d3f Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 15:54:58 -0700 Subject: [PATCH 17/23] fix: reject unavailable headless sudo preflight --- pkg/sudo/sudo.go | 49 +++++++++++++++++++++++++++++++++---------- pkg/sudo/sudo_test.go | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 pkg/sudo/sudo_test.go diff --git a/pkg/sudo/sudo.go b/pkg/sudo/sudo.go index d75c18ef..9518e096 100644 --- a/pkg/sudo/sudo.go +++ b/pkg/sudo/sudo.go @@ -29,10 +29,14 @@ type Gater interface { var Default Gater = &systemGater{} // systemGater implements Gater using the real sudo check and optional password prompt. -type systemGater struct{} +type systemGater struct { + checkStatus func() Status + runCommand func(*exec.Cmd) error + stdin *os.File +} func (g *systemGater) Gate(t *terminal.Terminal, confirmer terminal.Confirmer, reason string, assumeYes bool) error { - status := check() + status := g.status() if status == StatusRoot { return nil } @@ -51,15 +55,17 @@ func (g *systemGater) Gate(t *terminal.Terminal, confirmer terminal.Confirmer, r } if status == StatusUncached { - if exec.Command("sudo", "-n", "-v").Run() != nil { //nolint:gosec // intentional sudo -n -v - if isTTY(os.Stdin) { - cmd := exec.Command("sudo", "-v") //nolint:gosec // intentional sudo -v - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("sudo authentication failed: %w", err) - } + if err := g.run(exec.Command("sudo", "-n", "-v")); err != nil { //nolint:gosec // intentional sudo -n -v + stdin := g.input() + if !isTTY(stdin) { + return fmt.Errorf("sudo authentication unavailable without an interactive terminal: %w", err) + } + cmd := exec.Command("sudo", "-v") //nolint:gosec // intentional sudo -v + cmd.Stdin = stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := g.run(cmd); err != nil { + return fmt.Errorf("sudo authentication failed: %w", err) } } } @@ -67,6 +73,27 @@ func (g *systemGater) Gate(t *terminal.Terminal, confirmer terminal.Confirmer, r return nil } +func (g *systemGater) status() Status { + if g.checkStatus != nil { + return g.checkStatus() + } + return check() +} + +func (g *systemGater) run(cmd *exec.Cmd) error { + if g.runCommand != nil { + return g.runCommand(cmd) + } + return cmd.Run() //nolint:wrapcheck // Gate adds branch-specific sudo authentication context. +} + +func (g *systemGater) input() *os.File { + if g.stdin != nil { + return g.stdin + } + return os.Stdin +} + // check returns the current sudo status. func check() Status { if os.Getuid() == 0 { diff --git a/pkg/sudo/sudo_test.go b/pkg/sudo/sudo_test.go new file mode 100644 index 00000000..ba313faf --- /dev/null +++ b/pkg/sudo/sudo_test.go @@ -0,0 +1,38 @@ +package sudo + +import ( + "errors" + "os" + "os/exec" + "testing" + + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/stretchr/testify/require" +) + +type sudoTestConfirmer struct{} + +func (sudoTestConfirmer) ConfirmYesNo(string) bool { return true } + +func TestSystemGater_UncachedNonInteractiveSudoFailureIsReturned(t *testing.T) { + stdin, err := os.CreateTemp(t.TempDir(), "stdin") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, stdin.Close()) }) + + probeErr := errors.New("sudo credentials unavailable") + runCalls := 0 + gater := &systemGater{ + checkStatus: func() Status { return StatusUncached }, + runCommand: func(cmd *exec.Cmd) error { + runCalls++ + require.Equal(t, []string{"sudo", "-n", "-v"}, cmd.Args) + return probeErr + }, + stdin: stdin, + } + + err = gater.Gate(terminal.New(), sudoTestConfirmer{}, "Node-wide Brev SSH cleanup", true) + require.ErrorIs(t, err, probeErr) + require.ErrorContains(t, err, "sudo authentication unavailable without an interactive terminal") + require.Equal(t, 1, runCalls) +} From 17659ee4962712289036a92301e3af6ccf0f77c4 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 16:00:43 -0700 Subject: [PATCH 18/23] test: satisfy BYON command lint boundaries --- pkg/cmd/enablessh/enablessh.go | 4 +- pkg/cmd/enablessh/enablessh_test.go | 76 +++++++++++++++-------------- pkg/cmd/register/providers.go | 6 +-- pkg/cmd/register/register_test.go | 4 ++ 4 files changed, 48 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/enablessh/enablessh.go b/pkg/cmd/enablessh/enablessh.go index f239407a..24065857 100644 --- a/pkg/cmd/enablessh/enablessh.go +++ b/pkg/cmd/enablessh/enablessh.go @@ -149,11 +149,11 @@ func (p defaultSSHAccessProvisioner) Provision( brevPortID, err := register.ResolveSSHAccessPort(ctx, t, p.prompter, p.nodeClients, tokenProvider, reg, node) if err != nil { - return err + return err //nolint:wrapcheck // ResolveSSHAccessPort returns operation-specific user guidance. } if err := register.SetupAndRegisterNodeSSHAccess(ctx, t, p.nodeClients, tokenProvider, reg, brevUser, linuxUsername, brevPortID); err != nil { - return err + return err //nolint:wrapcheck // SetupAndRegisterNodeSSHAccess supplies provisioning context. } return nil diff --git a/pkg/cmd/enablessh/enablessh_test.go b/pkg/cmd/enablessh/enablessh_test.go index 6b14bca0..1beb3a88 100644 --- a/pkg/cmd/enablessh/enablessh_test.go +++ b/pkg/cmd/enablessh/enablessh_test.go @@ -261,14 +261,14 @@ func (f *fakeNodeService) AddNode(_ context.Context, _ *connect.Request[nodev1.A return connect.NewResponse(&nodev1.AddNodeResponse{}), nil } -func startFakeServer(t *testing.T, svc *fakeNodeService) (enableSSHDeps, *httptest.Server) { +func startFakeServer(t *testing.T, svc *fakeNodeService) enableSSHDeps { t.Helper() _, handler := nodev1connect.NewExternalNodeServiceHandler(svc) server := httptest.NewServer(handler) t.Cleanup(server.Close) return enableSSHDeps{ nodeClients: mockNodeClientFactory{serverURL: server.URL}, - }, server + } } type enableSSHOrder struct{ entries []string } @@ -292,6 +292,7 @@ type orderedRegistrationStore struct { func (s *orderedRegistrationStore) Save(*register.DeviceRegistration) error { return errors.New("Save must not be called") } + func (s *orderedRegistrationStore) Load() (*register.DeviceRegistration, error) { return s.reg, s.err } @@ -410,7 +411,7 @@ func TestRunEnableSSH_MissingBackendNodeDoesNotConnectOrProvision(t *testing.T) return &nodev1.GetNodeResponse{}, nil }, } - deps, _ := startFakeServer(t, svc) + deps := startFakeServer(t, svc) registrationStore := &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} deps.platform = orderedPlatform{order: order} deps.registrationStore = registrationStore @@ -431,7 +432,7 @@ func TestRunEnableSSH_ConnectedTunnelProvisionsSSH(t *testing.T) { return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil }, } - deps, _ := startFakeServer(t, svc) + deps := startFakeServer(t, svc) registrationStore := &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456", DisplayName: "joined-node"}} deps.platform = orderedPlatform{order: order} deps.registrationStore = registrationStore @@ -453,7 +454,7 @@ func TestRunEnableSSH_ReconnectsBeforeProvisioning(t *testing.T) { return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil }, } - deps, _ := startFakeServer(t, svc) + deps := startFakeServer(t, svc) deps.platform = orderedPlatform{order: order} deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} tunnel := &reconnectingTunnel{order: order, connected: &tunnelConnected} @@ -470,43 +471,44 @@ func TestRunEnableSSH_ReconnectsBeforeProvisioning(t *testing.T) { } func TestRunEnableSSH_TunnelFailureDoesNotProvision(t *testing.T) { - order := &enableSSHOrder{} - svc := &fakeNodeService{ - order: &order.entries, - getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { - return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + tests := []struct { + name string + tunnelErr error + wantErrMsg string + }{ + { + name: "generic reconnect failure", + tunnelErr: errors.New("tunnel failed"), + wantErrMsg: "enable SSH requires a connected Brev tunnel", + }, + { + name: "connection remains unconfirmed", + tunnelErr: errors.New("Brev tunnel connection was not confirmed"), + wantErrMsg: "Brev tunnel connection was not confirmed", }, } - deps, _ := startFakeServer(t, svc) - deps.platform = orderedPlatform{order: order} - deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} - deps.tunnel = orderedTunnel{order: order, err: errors.New("tunnel failed")} - deps.provisioner = orderedProvisioner{order: order} - err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + order := &enableSSHOrder{} + svc := &fakeNodeService{ + order: &order.entries, + getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { + return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil + }, + } + deps := startFakeServer(t, svc) + deps.platform = orderedPlatform{order: order} + deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} + deps.tunnel = orderedTunnel{order: order, err: tt.tunnelErr} + deps.provisioner = orderedProvisioner{order: order} - require.ErrorContains(t, err, "enable SSH requires a connected Brev tunnel") - require.NotContains(t, order.entries, "provision") -} + err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) -func TestRunEnableSSH_UnconfirmedTunnelDoesNotProvision(t *testing.T) { - order := &enableSSHOrder{} - svc := &fakeNodeService{ - order: &order.entries, - getNodeFn: func(*nodev1.GetNodeRequest) (*nodev1.GetNodeResponse, error) { - return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil - }, + require.ErrorContains(t, err, tt.wantErrMsg) + require.NotContains(t, order.entries, "provision") + }) } - deps, _ := startFakeServer(t, svc) - deps.platform = orderedPlatform{order: order} - deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} - deps.tunnel = orderedTunnel{order: order, err: errors.New("Brev tunnel connection was not confirmed")} - deps.provisioner = orderedProvisioner{order: order} - - err := runEnableSSH(context.Background(), terminal.New(), orderedEnableSSHStore{order: order, user: &entity.User{ID: "user_123"}}, deps) - - require.ErrorContains(t, err, "Brev tunnel connection was not confirmed") - require.NotContains(t, order.entries, "provision") } func TestRunEnableSSH_NeverAddsNode(t *testing.T) { @@ -517,7 +519,7 @@ func TestRunEnableSSH_NeverAddsNode(t *testing.T) { return &nodev1.GetNodeResponse{ExternalNode: &nodev1.ExternalNode{ExternalNodeId: "unode_123"}}, nil }, } - deps, _ := startFakeServer(t, svc) + deps := startFakeServer(t, svc) deps.platform = orderedPlatform{order: order} deps.registrationStore = &orderedRegistrationStore{order: order, exists: true, reg: ®ister.DeviceRegistration{ExternalNodeID: "unode_123", OrgID: "org_456"}} deps.tunnel = orderedTunnel{order: order} diff --git a/pkg/cmd/register/providers.go b/pkg/cmd/register/providers.go index 6e9a1e35..7d575402 100644 --- a/pkg/cmd/register/providers.go +++ b/pkg/cmd/register/providers.go @@ -55,7 +55,7 @@ type netBirdCommandRunner interface { type execNetBirdCommandRunner struct{} func (execNetBirdCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, name, args...).Output() + return exec.CommandContext(ctx, name, args...).Output() //nolint:wrapcheck // EnsureConnected adds operation context. } func (execNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { @@ -63,7 +63,7 @@ func (execNetBirdCommandRunner) Run(ctx context.Context, name string, args ...st cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + return cmd.Run() //nolint:wrapcheck // EnsureConnected adds operation context. } // Netbird handles NetBird installation and connectivity. @@ -138,7 +138,7 @@ func (n Netbird) EnsureConnected(ctx context.Context) error { for { select { case <-ctx.Done(): - return ctx.Err() + return fmt.Errorf("wait for Brev tunnel connection: %w", ctx.Err()) case <-confirmationCtx.Done(): if lastStatusErr != nil { return fmt.Errorf("Brev tunnel connection was not confirmed: %w", lastStatusErr) diff --git a/pkg/cmd/register/register_test.go b/pkg/cmd/register/register_test.go index 79cd1ac5..44e463b1 100644 --- a/pkg/cmd/register/register_test.go +++ b/pkg/cmd/register/register_test.go @@ -30,9 +30,11 @@ func (panicRegisterStore) GetCurrentUser() (*entity.User, error) { panic("GetCur func (panicRegisterStore) GetActiveOrganizationOrDefault() (*entity.Organization, error) { panic("GetActiveOrganizationOrDefault called") } + func (panicRegisterStore) GetOrganizationsByName(string) ([]entity.Organization, error) { panic("GetOrganizationsByName called") } + func (panicRegisterStore) ListOrganizations() ([]entity.Organization, error) { panic("ListOrganizations called") } @@ -130,10 +132,12 @@ func (p *recordingJoinPrompter) ConfirmYesNo(label string) bool { p.prompts = append(p.prompts, joinPrompt{kind: "confirm", label: label}) return true } + func (p *recordingJoinPrompter) Select(label string, items []string) string { p.prompts = append(p.prompts, joinPrompt{kind: "select", label: label}) return items[0] } + func (p *recordingJoinPrompter) Input(content terminal.PromptContent) string { p.prompts = append(p.prompts, joinPrompt{kind: "input", label: content.Label}) return "interactive-node" From d3cdc13ddd3d72b874983e88f51c69457b044799 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 18:32:04 -0700 Subject: [PATCH 19/23] docs: revise disable-ssh cleanup contract --- ...8-07-byon-network-ssh-separation-design.md | 113 ++++++++++++------ 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md index f34cf9bf..1734ccd6 100644 --- a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md +++ b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md @@ -34,6 +34,9 @@ commands, including the new separation from SSH. - Preserve compatible automation through deprecated `register` and `deregister` aliases, with actionable migration output. - Make partial teardown failures visible and safely retryable. +- Let `disable-ssh` make best-effort progress without automatic elevation while + clearly directing non-root users to retry with `sudo` when public-key cleanup + is incomplete. ## Non-goals @@ -52,6 +55,13 @@ commands, including the new separation from SSH. installation is a separate follow-up. - Forcibly terminating already-established SSH sessions. Key removal prevents future authentication but does not kill active sessions. +- Automatically elevating or re-executing the Brev binary for + `disable-ssh`. Users explicitly choose whether to run the command with + `sudo`. +- Promising that root can rewrite every `authorized_keys` file. Immutable files, + read-only filesystems, malformed account data, and concurrent modification can + still make cleanup fail; completion is represented by the command's exit + status. ## Naming Rationale @@ -210,32 +220,50 @@ The flow is: 1. Verify Linux compatibility. 2. Load local registration; if absent, direct the user to `brev join`. -3. Authenticate, fetch the registered backend node, and snapshot every active - `SSHAccess` tuple. -4. Show a node-wide confirmation with the grant and Linux-account counts. State - that active sessions are not forcibly terminated. -5. Obtain sudo authorization for node-wide local key cleanup. -6. When active grants exist, ensure the existing Brev tunnel is connected so - remote key revocation can complete. Reconnect existing membership - automatically, but never join. If no grants exist, skip this network - requirement and continue to orphaned local-key cleanup. -7. Call `RevokeNodeSSHAccess` sequentially for every tuple while the node and +3. Show a node-wide confirmation using the locally registered device identity. + State that active sessions are not forcibly terminated. Remote grant counts + are not required before confirmation because authentication and backend + access must not block the local cleanup phase. +4. When the effective UID is not root, write this warning to stderr before + confirmation: + + ```text + Warning: not running as root; public key cleanup may be incomplete. Re-run + "sudo brev disable-ssh" to allow cleanup across all local accounts. + ``` + +5. Enumerate accounts reported by the local OS account database at the current + process privilege level and inspect only each account's + `.ssh/authorized_keys`. Remove only lines carrying Brev's current + `#brev-portID:...` marker or legacy `# brev-cli` marker. Attempt every + account, retain partial counts, and aggregate contextual errors rather than + stopping at the first unreadable or unwritable account. +6. Retain any local cleanup error and continue. Authenticate, fetch the current + registered backend node, and snapshot every remaining `SSHAccess` tuple. An + authentication or lookup failure is recorded as an incomplete remote-record + cleanup rather than hiding local progress. +7. When active records exist, ensure the existing Brev tunnel is connected so + remote revocation can complete. Reconnect existing membership automatically, + but never join. If no records exist, skip the tunnel and revocation work. +8. Call `RevokeNodeSSHAccess` sequentially for every tuple while the node and its referenced ports still exist. Sequential execution avoids concurrent rewrites of one Linux account's `authorized_keys`. Attempt all entries and aggregate contextual failures. -8. If any backend revocation fails, return nonzero and do not perform the broad - local sweep. Successful revocations remain successful; a retry fetches and - processes the remaining records. -9. Once no backend access records remain, enumerate accounts reported by the - local OS account database and inspect only each account's - `.ssh/authorized_keys`. Remove only lines carrying Brev's current - `#brev-portID:...` marker or legacy `# brev-cli` marker. -10. Report success only after both authoritative revocation and local tagged-key - cleanup succeed. +9. After all independent work has been attempted, return a joined nonzero error + for either incomplete obligation. Local failures are reported as `failed to + clean up public keys`; authentication, lookup, tunnel, or revocation failures + are reported as `failed to remove remote SSH access records`. +10. Report overall success only after both local tagged-key cleanup and remote + record revocation succeed. No-access and no-key states are successful, making the command safely -repeatable. If the local sweep fails after backend revocation, membership and -registration remain intact so a retry can complete the sweep. +repeatable. A retry does not rewrite files without Brev markers, fetches a fresh +backend access snapshot, skips already-removed records, and attempts only work +that remains. Membership and registration remain intact after every outcome so +either side can be retried. In particular, a non-root partial cleanup can be +retried with `sudo brev disable-ssh`; local cleanup runs before Brev +authentication so root's separate home or login state cannot prevent the key +sweep from being attempted. `disable-ssh` does not remove the backend node, stop or uninstall NetBird, delete registration, stop sshd, or close ports. Ports remain because the current API @@ -304,13 +332,17 @@ return nonzero rather than producing a false successful completion. - `pkg/cmd/deregister` retains its internal package name but owns only leave orchestration. Its direct authorized-key removal dependency is removed. - `pkg/cmd/disablessh` is a focused new package with injected dependencies for - registration, node lookup, tunnel connectivity, sudo, confirmation, grant - revocation, and local account key cleanup. + registration, node lookup, tunnel connectivity, confirmation, effective-UID + detection, grant revocation, and local account key cleanup. - A narrow local key-cleanup abstraction enumerates account homes and removes only Brev-tagged lines from each account's `.ssh/authorized_keys`, without recursing through home directories. Rewrites preserve unrelated lines, ownership, and file mode. Tests use a fake rather than touching real home directories. +- `disable-ssh` invokes that cleaner directly at the current effective UID. It + has no sudo gate, hidden helper argument, same-binary privileged re-execution, + or special dispatch in `main.go`. The shared `pkg/sudo` behavior remains for + commands that still require it. - Tunnel management gains a strict connected operation suitable for SSH preconditions. It can start the service and run `netbird up` for existing membership, but returns an error unless connectivity is positively confirmed. @@ -340,10 +372,14 @@ return nonzero rather than producing a false successful completion. - Membership validation and strict tunnel connectivity precede every `enable-ssh` mutation. -- `disable-ssh` attempts every backend revocation, reports each failed tuple with - user, Linux account, and port context, and returns a combined error. -- The local node-wide key sweep runs only after authoritative access records are - gone, preventing local cleanup from stranding backend revocation. +- `disable-ssh` attempts local key cleanup before authentication or network + work, then attempts remote cleanup even when local cleanup is incomplete. +- `disable-ssh` attempts every reachable backend revocation, reports each failed + tuple with user, Linux account, and port context, and joins local and remote + failures into one final error. +- A local success with a remote failure fails closed on the host but may leave + stale backend records. A remote success with a local failure leaves tagged + keys on one or more accounts. Both cases return nonzero and remain retryable. - `leave` deletes registration last and treats backend not-found as an idempotent retry condition. - Neither teardown command prints success after an incomplete operation. @@ -393,18 +429,25 @@ Tests verify: Tests verify: - Confirmation describes node-wide scope and can be bypassed with `--approve`. +- A non-root invocation warns that cleanup may be incomplete and recommends + `sudo brev disable-ssh`; a root invocation does not print that warning. - Every active access tuple is revoked exactly once. - Active grants require a connected tunnel; a disconnected existing tunnel is reconnected before revocation. -- With no active grants, tunnel failure does not block the orphaned local-key - sweep. +- Local cleanup occurs before Brev authentication, node lookup, tunnel access, + or revocation. - All tuples are attempted even when one fails, and errors are aggregated. -- The local tagged-key sweep does not run after any revocation failure. -- After successful revocation, current and legacy Brev markers are removed - across account homes while unrelated keys, ownership, and file modes remain - intact. +- Local cleanup errors do not block authentication or remote record cleanup; + remote errors do not erase or misreport local progress. +- Simultaneous local and remote failures are joined, retain both underlying + causes, and do not print overall success. +- Current and legacy Brev markers are removed across accessible account homes + while unrelated keys, ownership, and file modes remain intact. - No-access and no-key runs succeed. -- A local sweep failure returns nonzero and is retryable. +- A partial non-root run followed by a root retry does not rewrite already-clean + files or re-revoke records absent from the fresh backend snapshot. +- Authentication or backend lookup failure still attempts local cleanup and + returns an incomplete remote-record error. - No node removal, NetBird teardown, registration deletion, sshd operation, or port close occurs. @@ -439,6 +482,8 @@ separately and will not be attributed to this change. - Onboarding documents show `enable-ssh` as an explicit post-join choice. - Offboarding documents show `disable-ssh` followed by `leave` for complete credential and membership removal. +- `disable-ssh` documentation explains best-effort non-root cleanup, its + nonzero partial-failure result, and the `sudo brev disable-ssh` retry. - Documentation states that `leave` alone makes the node unreachable over the Brev network but does not remove host keys. - Release notes call out both deprecated aliases and the SSH behavior change. From d15915595fca70e2b3e53b2ca9d30e19944604c9 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Mon, 10 Aug 2026 18:34:50 -0700 Subject: [PATCH 20/23] docs: clarify disable-ssh failure contract --- ...8-07-byon-network-ssh-separation-design.md | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md index 1734ccd6..52050d2b 100644 --- a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md +++ b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md @@ -220,18 +220,19 @@ The flow is: 1. Verify Linux compatibility. 2. Load local registration; if absent, direct the user to `brev join`. -3. Show a node-wide confirmation using the locally registered device identity. - State that active sessions are not forcibly terminated. Remote grant counts - are not required before confirmation because authentication and backend - access must not block the local cleanup phase. -4. When the effective UID is not root, write this warning to stderr before - confirmation: +3. When the effective UID is not root, write this warning to stderr: ```text Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts. ``` +4. Show a node-wide confirmation using the locally registered device identity. + State that active sessions are not forcibly terminated. Remote grant counts + are not required before confirmation because authentication and backend + access must not block the local cleanup phase. `--approve` skips this prompt + but does not suppress either safety warning. + 5. Enumerate accounts reported by the local OS account database at the current process privilege level and inspect only each account's `.ssh/authorized_keys`. Remove only lines carrying Brev's current @@ -431,6 +432,10 @@ Tests verify: - Confirmation describes node-wide scope and can be bypassed with `--approve`. - A non-root invocation warns that cleanup may be incomplete and recommends `sudo brev disable-ssh`; a root invocation does not print that warning. +- `--approve` skips confirmation without suppressing the non-root or active- + session warnings. +- No disable flow invokes a sudo gate, subprocess runner, hidden helper mode, or + other automatic elevation path. - Every active access tuple is revoked exactly once. - Active grants require a connected tunnel; a disconnected existing tunnel is reconnected before revocation. @@ -440,7 +445,9 @@ Tests verify: - Local cleanup errors do not block authentication or remote record cleanup; remote errors do not erase or misreport local progress. - Simultaneous local and remote failures are joined, retain both underlying - causes, and do not print overall success. + causes, include both `failed to clean up public keys` and `failed to remove + remote SSH access records`, and do not print overall success. Each single-side + failure includes only its applicable classification and underlying cause. - Current and legacy Brev markers are removed across accessible account homes while unrelated keys, ownership, and file modes remain intact. - No-access and no-key runs succeed. From a6afd9176ccb000969ae38890cdc635c9f8b8577 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Tue, 11 Aug 2026 15:28:57 -0700 Subject: [PATCH 21/23] docs: plan disable-ssh best-effort cleanup --- ...6-08-11-disable-ssh-best-effort-cleanup.md | 948 ++++++++++++++++++ 1 file changed, 948 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md diff --git a/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md b/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md new file mode 100644 index 00000000..a68c1207 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md @@ -0,0 +1,948 @@ +# Disable SSH Best-Effort Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `brev disable-ssh` attempt local Brev-key cleanup directly at the caller's privilege level before backend work, continue through independent failures, and support an explicit, idempotent `sudo brev disable-ssh` retry without a privileged helper mode. + +**Architecture:** Keep the descriptor-safe Linux account sweep unchanged, but invoke it directly from the command with the current effective UID. Use a command-local Cobra persistent hook to defer the root command's normal pre-run until after the local sweep, then split the confirmed operation into two independently attempted obligations—local tagged-key cleanup and remote SSH-record removal—and join their classified errors at the end. Remove the same-binary sudo re-execution path and restore `main.go` to generic CLI startup only. + +**Tech Stack:** Go 1.25, Cobra, ConnectRPC/protobuf, the repository's `terminal` and `errors` packages, `os.Geteuid`, and existing Linux `golang.org/x/sys/unix` key-rewrite code. + +## Global Constraints + +- Implement only in `/Users/pratpatel/code/brev-cli-byon-network-join` on branch `codex/byon-network-join`. +- Treat `docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md` at commit `d1591559` or later as the approved behavioral contract. +- This follow-up supersedes only the privileged-helper and backend-first `disable-ssh` portions of `docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md`; do not change the already-implemented `join`, `enable-ssh`, `leave`, alias, or SSH-grant boundaries. +- `disable-ssh` remains Linux-only and still requires the global `/etc/brev/device_registration.json` registration before confirmation or mutation. +- Do not automatically invoke `sudo`, re-execute the Brev binary, add a hidden helper argument, or add command-specific dispatch to `main.go`. +- When `os.Geteuid() != 0`, stderr must include: `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.` +- `--approve` skips only confirmation; it does not suppress the non-root, node-wide, or active-session warnings. +- After confirmation, local key cleanup runs before `GetCurrentUser`, `GetNode`, NetBird reconnection, or `RevokeNodeSSHAccess`. +- The root Cobra `PersistentPreRunE` must not execute automatically before `disable-ssh`. Invoke it explicitly as the first remote-phase operation after local cleanup so version, feature-flag, user-home, or `--user` failures are classified as remote cleanup errors. +- Local cleanup and remote-record cleanup are independent obligations. Attempt remote cleanup after a local error, retain both causes, and return nonzero if either obligation is incomplete. +- Classify local failures with `failed to clean up public keys` and auth, node lookup, tunnel, or revocation failures with `failed to remove remote SSH access records`. +- Never print the overall `SSH access disabled` success line when either obligation fails. Partial key counts may be printed as progress, not success. +- Preserve idempotency: marker-free files are not rewritten; each run fetches a fresh `SSHAccess` snapshot; absent grants are not re-revoked; registration and membership are never removed. +- Do not change the secure Linux file traversal/rewrite implementation in `localkeys_linux.go` or its race, symlink, FIFO, ownership, and mode guarantees. +- Keep the generic `pkg/sudo` non-interactive fix. Other commands still depend on it. +- Use TDD for every behavior change: add the focused failing test, observe the expected failure, implement the smallest change, rerun, and commit. +- Run `gofmt` on touched Go files. Keep errors wrapped with `%w` and preserve causes for `errors.Is`/`require.ErrorIs`. + +## File Map + +### Modify + +- `pkg/cmd/disablessh/disablessh.go`: current-EUID warning, direct cleaner dependency, cleanup-first state machine, remote cleanup helper, joined error classification, and registration-based confirmation output. +- `pkg/cmd/disablessh/disablessh_test.go`: warning/root behavior, cleanup-first ordering, independent failure continuation, exact error labels, cancellation, retry idempotency, and removal of sudo-gater expectations. +- `pkg/cmd/disablessh/localkeys.go`: retain only account parsing, Brev-line filtering, account sweeping, and `newSystemLocalKeyCleaner`; delete privileged process/helper protocol. +- `pkg/cmd/disablessh/localkeys_test.go`: retain parser/filter/sweep tests; delete privileged runner and helper-mode tests. +- `main.go`: remove the `disablessh` early-dispatch branch and its command-specific imports. +- `pkg/integration/cli_output_compatibility_test.go`: prove the retired helper token reaches normal Cobra handling rather than a special main entrypoint. +- `pkg/sudo/sudo_test.go`: replace the stale disable-specific fixture reason with generic sudo-test wording; keep the generic non-interactive behavior unchanged. +- `docs/BYON.md`: document current-EUID cleanup, non-root failure behavior, error aggregation, and sudo retry. +- `.agents/skills/brev-cli/SKILL.md`: keep the bundled skill's BYON guidance aligned. +- `.agents/skills/brev-cli/reference/commands.md`: replace the privileged-root-sweep description with the explicit retry contract. +- `CHANGELOG.md`: call out the changed `disable-ssh` elevation and retry behavior. + +### Deliberately Unchanged + +- `pkg/cmd/disablessh/localkeys_linux.go` and `localkeys_linux_test.go`: the secure rewrite and no-marker no-rewrite behavior already satisfy the new contract. +- `pkg/cmd/disablessh/localkeys_unsupported.go`: the direct cleaner already has a non-Linux companion and the command rejects non-Linux platforms first. +- `pkg/sudo`: remains available to `join` and `leave`; `disable-ssh` simply stops depending on it. +- `pkg/cmd/cmd.go`: continues to register the ordinary top-level `disable-ssh` Cobra command exactly once. + +--- + +### Task 1: Stop Automatically Elevating `disable-ssh` + +**Files:** + +- Modify: `pkg/cmd/disablessh/disablessh.go:4-50,119-133` +- Modify: `pkg/cmd/disablessh/disablessh_test.go:92-118,232-278,313-350,515-533` + +**Interfaces:** + +- Consumes: existing `localKeyCleaner.RemoveBrevKeys(context.Context) (KeyCleanupResult, error)` and `newSystemLocalKeyCleaner()`. +- Produces: `disableSSHDeps.geteuid func() int`; production defaults `geteuid` to `os.Geteuid` and `keyCleaner` to `newSystemLocalKeyCleaner()`. + +- [ ] **Step 1: Add failing direct-cleaner and warning tests** + +Delete `disableSSHTestGater`, remove `gater` from `disableSSHTestHarness`, add an effective UID to the harness, and make the injected getter mutable per test. Add a confirmation hook so warning order is observable: + +```go +type disableSSHTestConfirmer struct { + events *[]string + answer bool + calls int + labels []string + beforeConfirm func() +} + +func (c *disableSSHTestConfirmer) ConfirmYesNo(label string) bool { + if c.beforeConfirm != nil { + c.beforeConfirm() + } + c.calls++ + c.labels = append(c.labels, label) + recordDisableSSHEvent(c.events, "confirm") + return c.answer +} + +type disableSSHTestHarness struct { + events []string + store *disableSSHTestStore + registrations *disableSSHTestRegistrationStore + confirmer *disableSSHTestConfirmer + tunnel *disableSSHTestTunnel + cleaner *disableSSHTestKeyCleaner + client *disableSSHRecordingClient + deps disableSSHDeps + euid int +} + +func newDisableSSHTestHarness(accesses ...*nodev1.SSHAccess) *disableSSHTestHarness { + h := &disableSSHTestHarness{euid: 0} + h.store = &disableSSHTestStore{events: &h.events} + h.registrations = &disableSSHTestRegistrationStore{ + events: &h.events, + exists: true, + reg: ®ister.DeviceRegistration{ + ExternalNodeID: "node_123", + DisplayName: "owned-node", + OrgID: "org_123", + OrgName: "owned-org", + }, + } + h.confirmer = &disableSSHTestConfirmer{events: &h.events, answer: true} + h.tunnel = &disableSSHTestTunnel{events: &h.events} + h.cleaner = &disableSSHTestKeyCleaner{ + events: &h.events, + result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}, + } + h.client = &disableSSHRecordingClient{ + events: &h.events, + node: &nodev1.ExternalNode{ExternalNodeId: "node_123", Name: "owned-node", SshAccess: accesses}, + revokeErrors: make(map[int]error), + } + h.deps = disableSSHDeps{ + platform: &disableSSHTestPlatform{compatible: true, events: &h.events}, + confirmer: h.confirmer, + geteuid: func() int { return h.euid }, + tunnel: h.tunnel, + nodeClients: disableSSHTestNodeClientFactory{client: h.client}, + registrationStore: h.registrations, + keyCleaner: h.cleaner, + } + return h +} +``` + +Add these tests: + +```go +func TestDefaultDisableSSHDeps_UsesDirectSystemCleaner(t *testing.T) { + deps := defaultDisableSSHDeps() + require.NotNil(t, deps.geteuid) + _, ok := deps.keyCleaner.(systemLocalKeyCleaner) + require.True(t, ok) +} + +func TestRunDisableSSH_NonRootWarnsAndApproveDoesNotSuppressWarnings(t *testing.T) { + h := newDisableSSHTestHarness() + h.euid = 1000 + + _, stderr, err := h.run(t, true) + require.NoError(t, err) + require.Zero(t, h.confirmer.calls) + require.Contains(t, stderr, "Warning: not running as root; public key cleanup may be incomplete.") + require.Contains(t, stderr, `Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) + require.Contains(t, stderr, "node-wide") + require.Contains(t, stderr, "active SSH sessions are not forcibly terminated") +} + +func TestRunDisableSSH_RootDoesNotPrintNonRootWarning(t *testing.T) { + h := newDisableSSHTestHarness() + h.euid = 0 + + _, stderr, err := h.run(t, true) + require.NoError(t, err) + require.NotContains(t, stderr, "not running as root") +} + +func TestRunDisableSSH_NonRootWarningIsWrittenBeforeConfirmation(t *testing.T) { + h := newDisableSSHTestHarness() + h.euid = 1000 + var warnings bytes.Buffer + h.confirmer.beforeConfirm = func() { + require.Contains(t, warnings.String(), "not running as root") + } + + _, err := captureDisableSSHStdout(t, func(term *terminal.Terminal) error { + return runDisableSSH(context.Background(), term, &warnings, h.store, h.deps, false) + }) + require.NoError(t, err) +} +``` + +Apply these exact legacy-test changes so the package compiles after deleting the fake gater: + +- Delete every `require.Zero(t, h.gater.calls)` and `h.gater.reasons` assertion. +- Rename `TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup` to `TestRunDisableSSH_CancelStopsBeforeTunnelRevocationAndCleanup`. +- In `TestRunDisableSSH_ConnectsBeforeFirstRevocation`, temporarily require `tunnel`, `revoke:user_1`, then `cleanup`; Task 2 will move cleanup first. +- In `TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys`, require only `cleanup` for the mutation subsequence. +- Rename `TestRunDisableSSH_StateMachineOrdersPreflightConfirmationAndSudo` to `TestRunDisableSSH_StateMachineHasNoAutomaticElevation` and require this exact Task 1 sequence: + + ```go + require.Equal(t, []string{ + "platform", + "registration-exists", + "registration-load", + "auth", + "get-node", + "confirm", + "tunnel", + "revoke:user_1", + "cleanup", + }, h.events) + ``` + +At this task boundary, backend-first ordering otherwise remains unchanged. + +- [ ] **Step 2: Run the focused tests and observe the expected failure** + +Run: + +```bash +go test ./pkg/cmd/disablessh -run 'Test(DefaultDisableSSHDeps_UsesDirectSystemCleaner|RunDisableSSH_(NonRootWarnsAndApproveDoesNotSuppressWarnings|RootDoesNotPrintNonRootWarning|NonRootWarningIsWrittenBeforeConfirmation))' -count=1 +``` + +Expected: FAIL because `disableSSHDeps` has no `geteuid`, defaults to `newPrivilegedLocalKeyCleaner`, and does not print the non-root warning. + +- [ ] **Step 3: Replace the sudo gate with a direct current-EUID dependency** + +In `disablessh.go`, remove the `pkg/sudo` import, add `os`, and make the dependency shape exactly: + +```go +type disableSSHDeps struct { + platform externalnode.PlatformChecker + confirmer terminal.Confirmer + geteuid func() int + tunnel register.NetBirdConnector + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore + keyCleaner localKeyCleaner +} + +func defaultDisableSSHDeps() disableSSHDeps { + return disableSSHDeps{ + platform: register.LinuxPlatform{}, + confirmer: register.TerminalPrompter{}, + geteuid: os.Geteuid, + tunnel: register.Netbird{}, + nodeClients: register.DefaultNodeClientFactory{}, + registrationStore: register.NewFileRegistrationStore(), + keyCleaner: newSystemLocalKeyCleaner(), + } +} +``` + +After normalizing a nil warning writer and before confirmation, add: + +```go +if deps.geteuid() != 0 { + _, _ = fmt.Fprintln(warnings, `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) +} +``` + +Delete the entire `deps.gater.Gate(...)` block. Do not add any replacement sudo prompt or subprocess. + +- [ ] **Step 4: Format and run the full command package** + +Run: + +```bash +gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go +go test ./pkg/cmd/disablessh -count=1 +``` + +Expected: PASS. Existing backend-first behavior remains green while automatic elevation and its test double are gone. + +- [ ] **Step 5: Commit the privilege-boundary change** + +```bash +git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go +git commit -m "refactor: stop auto-elevating disable-ssh" +``` + +--- + +### Task 2: Attempt Local and Remote Cleanup Independently + +**Files:** + +- Modify: `pkg/cmd/disablessh/disablessh.go:75-198` +- Modify: `pkg/cmd/disablessh/disablessh_test.go:313-551` + +**Interfaces:** + +- Consumes: `disableSSHDeps` from Task 1, `cmdcontext.InvokeParentPersistentPreRun`, `register.FetchRegisteredNode`, `revokeSSHAccesses`, and `breverrors.Join`. +- Produces: `disableSSHDeps.prepareRemote func() error` and `removeRemoteSSHAccessRecords(context.Context, DisableSSHStore, disableSSHDeps, *register.DeviceRegistration) error`; `runDisableSSH` always invokes the local cleaner before this helper after confirmation. + +- [ ] **Step 1: Replace backend-first tests with cleanup-first failure tests** + +Add or replace tests with the following behavior: + +```go +func TestRunDisableSSH_CleanupRunsBeforeAuthenticationAndAuthFailureIsRemoteError(t *testing.T) { + authErr := errors.New("authentication failed") + h := newDisableSSHTestHarness() + h.store.currentUserErr = authErr + + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, authErr) + require.Contains(t, err.Error(), "failed to remove remote SSH access records") + require.NotContains(t, err.Error(), "failed to clean up public keys") + requireOrderedSubsequence(t, h.events, "registration-load", "cleanup", "auth") + require.NotContains(t, stdout, "SSH access disabled") +} + +func TestNewCmdDisableSSH_ParentPreRunFailureOccursAfterLocalCleanup(t *testing.T) { + parentErr := errors.New("root pre-run failed") + h := newDisableSSHTestHarness() + root := &cobra.Command{ + Use: "brev", + PersistentPreRunE: func(*cobra.Command, []string) error { + recordDisableSSHEvent(&h.events, "parent-pre-run") + return parentErr + }, + } + + _, err := captureDisableSSHStdout(t, func(term *terminal.Terminal) error { + root.AddCommand(newCmdDisableSSH(term, h.store, h.deps)) + root.SetArgs([]string{"disable-ssh", "--approve"}) + root.SetErr(io.Discard) + return root.Execute() + }) + require.ErrorIs(t, err, parentErr) + require.Contains(t, err.Error(), "failed to remove remote SSH access records") + requireOrderedSubsequence(t, h.events, "cleanup", "parent-pre-run") + require.Equal(t, []string{"parent-pre-run"}, filterDisableSSHEvents(h.events, "parent-pre-run")) + require.Equal(t, 1, h.cleaner.calls) + require.Zero(t, h.store.currentUserCalls) +} + +func TestRunDisableSSH_LocalFailureStillRemovesRemoteRecords(t *testing.T) { + cleanupErr := errors.New("alice authorized_keys is not writable") + h := newDisableSSHTestHarness(testSSHAccess("user_1", "alice", "port_1")) + h.cleaner.result = KeyCleanupResult{AccountsScanned: 2, AccountsChanged: 1, KeysRemoved: 1} + h.cleaner.err = cleanupErr + + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, cleanupErr) + require.Contains(t, err.Error(), "failed to clean up public keys") + require.NotContains(t, err.Error(), "failed to remove remote SSH access records") + require.Len(t, h.client.revokeRequests, 1) + requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node", "tunnel", "revoke:user_1") + require.Contains(t, stdout, "1 keys removed") + require.NotContains(t, stdout, "SSH access disabled") + require.Zero(t, h.client.removeNodeCalls) + require.Zero(t, h.client.closePortCalls) + require.Zero(t, h.tunnel.uninstallCalls) + require.Zero(t, h.registrations.deleteCalls) +} + +func TestRunDisableSSH_LocalAndRemoteFailuresAreJoined(t *testing.T) { + cleanupErr := errors.New("local cleanup failed") + revokeErr := errors.New("remote revoke failed") + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + h.cleaner.err = cleanupErr + h.client.revokeErrors[0] = revokeErr + + stdout, _, err := h.run(t, true) + require.ErrorIs(t, err, cleanupErr) + require.ErrorIs(t, err, revokeErr) + require.Contains(t, err.Error(), "failed to clean up public keys") + require.Contains(t, err.Error(), "failed to remove remote SSH access records") + require.Equal(t, 1, h.cleaner.calls) + require.Len(t, h.client.revokeRequests, 1) + require.NotContains(t, stdout, "SSH access disabled") +} + +func TestRunDisableSSH_NonRootFailureThenRootRetrySkipsRemovedRecords(t *testing.T) { + cleanupErr := errors.New("another account is not writable") + h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) + h.euid = 1000 + h.cleaner.err = cleanupErr + + _, _, err := h.run(t, true) + require.ErrorIs(t, err, cleanupErr) + require.Len(t, h.client.revokeRequests, 1) + require.Equal(t, 1, h.tunnel.ensureCalls) + + h.euid = 0 + h.cleaner.err = nil + h.client.node.SshAccess = nil + h.cleaner.result = KeyCleanupResult{} + _, _, err = h.run(t, true) + require.NoError(t, err) + require.Len(t, h.client.revokeRequests, 1, "second run must not re-revoke an absent record") + require.Equal(t, 1, h.tunnel.ensureCalls, "second run with no records must skip the tunnel") + require.Equal(t, 2, h.cleaner.calls, "each run rechecks local state idempotently") +} +``` + +Update the cancellation test to require exactly: + +```go +require.Equal(t, []string{"platform", "registration-exists", "registration-load", "confirm"}, h.events) +require.Zero(t, h.cleaner.calls) +require.Zero(t, h.store.currentUserCalls) +``` + +Replace `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts` with a registration-only preflight assertion: + +```go +func TestRunDisableSSH_ConfirmationOutputUsesLocalRegistration(t *testing.T) { + h := newDisableSSHTestHarness() + h.store.currentUserErr = errors.New("backend unavailable after confirmation") + + stdout, _, err := h.run(t, true) + require.Error(t, err) + require.Contains(t, stdout, "owned-node") + require.Contains(t, stdout, "node_123") + require.NotContains(t, stdout, "SSH grants:") + require.NotContains(t, stdout, "Linux accounts:") +} +``` + +Use these replacements rather than leaving contradictory backend-first tests in the file: + +- Replace `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts` with `TestRunDisableSSH_ConfirmationOutputUsesLocalRegistration`. +- Replace `TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup` with `TestRunDisableSSH_RevocationFailureOccursAfterLocalCleanup`. +- Rename `TestRunDisableSSH_NotFoundRevocationBlocksLocalCleanup` to `TestRunDisableSSH_NotFoundRevocationPreservesCauseAfterLocalCleanup`. +- Rename `TestRunDisableSSH_TunnelFailureStopsBeforeRevocationAndCleanup` to `TestRunDisableSSH_TunnelFailureOccursAfterLocalCleanup`. +- Replace `TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership` with `TestRunDisableSSH_LocalFailureStillRemovesRemoteRecords`; the existing no-membership-mutation test continues to protect node, registration, NetBird, and port boundaries. +- Rename `TestRunDisableSSH_BackendNodeFailureStopsBeforeConfirmationAndMutation` to `TestRunDisableSSH_BackendNodeFailureOccursAfterConfirmationAndCleanup`. + +- [ ] **Step 2: Run the focused state-machine tests and observe RED** + +Run: + +```bash +go test ./pkg/cmd/disablessh -run 'Test(NewCmdDisableSSH_ParentPreRunFailureOccursAfterLocalCleanup|RunDisableSSH_(CleanupRunsBeforeAuthenticationAndAuthFailureIsRemoteError|LocalFailureStillRemovesRemoteRecords|LocalAndRemoteFailuresAreJoined|NonRootFailureThenRootRetrySkipsRemovedRecords|ConfirmationOutputUsesLocalRegistration))' -count=1 +``` + +Expected: FAIL because current code authenticates before confirmation, stops on the first obligation error, prints backend-derived counts, and runs cleanup last. + +- [ ] **Step 3: Extract remote cleanup and implement the two-obligation state machine** + +Add `github.com/brevdev/brev-cli/pkg/cmdcontext` to `disablessh.go`. Extend the dependency struct and defaults with: + +```go +type disableSSHDeps struct { + platform externalnode.PlatformChecker + confirmer terminal.Confirmer + geteuid func() int + prepareRemote func() error + tunnel register.NetBirdConnector + nodeClients externalnode.NodeClientFactory + registrationStore register.RegistrationStore + keyCleaner localKeyCleaner +} + +func defaultDisableSSHDeps() disableSSHDeps { + return disableSSHDeps{ + platform: register.LinuxPlatform{}, + confirmer: register.TerminalPrompter{}, + geteuid: os.Geteuid, + prepareRemote: func() error { return nil }, + tunnel: register.Netbird{}, + nodeClients: register.DefaultNodeClientFactory{}, + registrationStore: register.NewFileRegistrationStore(), + keyCleaner: newSystemLocalKeyCleaner(), + } +} +``` + +Also set `prepareRemote: func() error { return nil }` in `newDisableSSHTestHarness`. Replace `newCmdDisableSSH` with this command-level hook structure; Cobra v1.8.1 executes only the closest persistent pre-run by default, so the child hook prevents the root hook from running before `RunE`: + +```go +func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableSSHDeps) *cobra.Command { + var approveFlag bool + cmd := &cobra.Command{ + Annotations: map[string]string{"configuration": ""}, + Use: "disable-ssh", + DisableFlagsInUseLine: true, + Short: "Disable all Brev-managed SSH access on this node", + Long: "Disable every Brev-managed SSH credential on this joined node without changing Brev network membership or the SSH daemon.", + Example: " brev disable-ssh\n brev disable-ssh --approve", + Args: cobra.NoArgs, + PersistentPreRunE: func(*cobra.Command, []string) error { + // Defer the parent's fallible setup until after local key cleanup. + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + runDeps := deps + runDeps.prepareRemote = func() error { + return cmdcontext.InvokeParentPersistentPreRun(cmd, args) + } + return runDisableSSH(cmd.Context(), t, cmd.ErrOrStderr(), store, runDeps, approveFlag) + }, + } + cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip confirmation prompt (assume yes)") + return cmd +} +``` + +Replace `runDisableSSH` with this complete state machine: + +```go +func runDisableSSH( + ctx context.Context, + t *terminal.Terminal, + warnings io.Writer, + store DisableSSHStore, + deps disableSSHDeps, + skipConfirm bool, +) error { //nolint:funlen // Ordered teardown state machine is intentionally explicit. + if !deps.platform.IsCompatible() { + return fmt.Errorf("brev disable-ssh is only supported on Linux") + } + + exists, err := deps.registrationStore.Exists() + if err != nil { + return fmt.Errorf("check joined-device registration: %w", err) + } + if !exists { + return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) + } + + reg, err := deps.registrationStore.Load() + if err != nil { + return fmt.Errorf("read joined-device registration: %w", err) + } + +if warnings == nil { + warnings = io.Discard +} + +t.Vprint("") +t.Vprint(t.White("══════════════════════════════════════════════════")) +t.Vprint(t.White(" Disabling Brev-managed SSH access")) +t.Vprint(t.White("══════════════════════════════════════════════════")) +t.Vprint("") +t.Vprintf(" Node: %s (%s)\n", reg.DisplayName, reg.ExternalNodeID) +t.Vprint("") + +if deps.geteuid() != 0 { + _, _ = fmt.Fprintln(warnings, `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) +} +_, _ = fmt.Fprintln(warnings, "Warning: this is a node-wide operation that removes all Brev-managed SSH credentials on this node.") +_, _ = fmt.Fprintln(warnings, "Warning: active SSH sessions are not forcibly terminated.") + +if !skipConfirm && !deps.confirmer.ConfirmYesNo("Disable all Brev-managed SSH access on this node?") { + t.Vprint("Disable SSH canceled.") + return nil +} + +result, localCleanupErr := deps.keyCleaner.RemoveBrevKeys(ctx) +if localCleanupErr != nil { + localCleanupErr = fmt.Errorf("failed to clean up public keys: %w", localCleanupErr) +} + +remoteCleanupErr := removeRemoteSSHAccessRecords(ctx, store, deps, reg) +if remoteCleanupErr != nil { + remoteCleanupErr = fmt.Errorf("failed to remove remote SSH access records: %w", remoteCleanupErr) +} + +if err := breverrors.Join(localCleanupErr, remoteCleanupErr); err != nil { + t.Vprintf(" Public key cleanup: %d keys removed; %d accounts changed.\n", result.KeysRemoved, result.AccountsChanged) + return fmt.Errorf("disable SSH incomplete: %w", err) +} + +t.Vprintf("%s SSH access disabled: %d keys removed; %d accounts changed.\n", t.Green(" ✓"), result.KeysRemoved, result.AccountsChanged) +return nil +} +``` + +Add the remote helper immediately below `runDisableSSH`: + +```go +func removeRemoteSSHAccessRecords( + ctx context.Context, + store DisableSSHStore, + deps disableSSHDeps, + reg *register.DeviceRegistration, +) error { + if err := deps.prepareRemote(); err != nil { + return fmt.Errorf("prepare Brev command: %w", err) + } + if _, err := store.GetCurrentUser(); err != nil { + return fmt.Errorf("authenticate Brev user: %w", err) + } + + node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, store, reg) + if err != nil { + return fmt.Errorf("fetch registered node: %w", err) + } + accesses := snapshotSSHAccess(node.GetSshAccess()) + if len(accesses) == 0 { + return nil + } + + if err := deps.tunnel.EnsureConnected(ctx); err != nil { + return fmt.Errorf("connect Brev tunnel: %w", err) + } + client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + if err := revokeSSHAccesses(ctx, client, reg.ExternalNodeID, accesses); err != nil { + return err + } + return nil +} +``` + +Delete `distinctLinuxAccountCount`; the command deliberately no longer authenticates before confirmation merely to render counts. + +- [ ] **Step 4: Update every affected legacy assertion explicitly** + +Use these exact replacements while retaining the existing tuple detail, sequential-call, and no-membership-mutation assertions: + +```go +// TestRunDisableSSH_ConnectsBeforeFirstRevocation +requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node", "tunnel", "revoke:user_1") + +// TestRunDisableSSH_StateMachineHasNoAutomaticElevation +require.Equal(t, []string{ + "platform", + "registration-exists", + "registration-load", + "confirm", + "cleanup", + "auth", + "get-node", + "tunnel", + "revoke:user_1", +}, h.events) + +// TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors +require.ErrorIs(t, err, firstErr) +require.ErrorIs(t, err, middleErr) +require.Contains(t, err.Error(), "failed to remove remote SSH access records") +require.Equal(t, 1, h.cleaner.calls) +require.Len(t, h.client.revokeRequests, 3) + +// Rename to TestRunDisableSSH_NotFoundRevocationPreservesCauseAfterLocalCleanup +require.Error(t, err) +require.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) +require.Contains(t, err.Error(), "failed to remove remote SSH access records") +require.Equal(t, 1, h.cleaner.calls) + +// Rename to TestRunDisableSSH_RevocationFailureOccursAfterLocalCleanup +require.Error(t, err) +require.Contains(t, err.Error(), "failed to remove remote SSH access records") +require.Equal(t, 1, h.cleaner.calls) +require.Len(t, h.client.revokeRequests, 2) + +// TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys +requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node") +require.Zero(t, h.tunnel.ensureCalls) +require.Empty(t, h.client.revokeRequests) + +// TestRunDisableSSH_IgnoresNilAccessEntries +require.NotContains(t, stdout, "SSH grants:") +require.Len(t, h.client.revokeRequests, 2) + +// Rename to TestRunDisableSSH_TunnelFailureOccursAfterLocalCleanup +require.ErrorIs(t, err, tunnelErr) +require.Contains(t, err.Error(), "failed to remove remote SSH access records") +require.Equal(t, 1, h.cleaner.calls) +require.Empty(t, h.client.revokeRequests) + +// Rename to TestRunDisableSSH_BackendNodeFailureOccursAfterConfirmationAndCleanup +requireOrderedSubsequence(t, h.events, "confirm", "cleanup", "auth", "get-node") +require.Equal(t, 1, h.cleaner.calls) +require.NotContains(t, stdout, "SSH access disabled") + +// TestRunDisableSSH_SuccessIncludesCleanupCounts +require.NoError(t, err) +require.Contains(t, stdout, "SSH access disabled") +require.Contains(t, stdout, "3 keys removed") +require.Contains(t, stdout, "2 accounts changed") +``` + +- [ ] **Step 5: Run formatting, the full package, and the local idempotency regression** + +Run: + +```bash +gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go +go test ./pkg/cmd/disablessh -count=1 +go test ./pkg/cmd/disablessh -run '^TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors$|^TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes$' -count=1 +``` + +Expected: PASS. The first command suite proves orchestration idempotency; the second preserves per-account continuation and marker-free byte idempotency. + +- [ ] **Step 6: Commit cleanup-first orchestration** + +```bash +git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go +git commit -m "fix: make disable-ssh cleanup retryable" +``` + +--- + +### Task 3: Remove the Privileged Helper Entrypoint + +**Files:** + +- Modify: `pkg/integration/cli_output_compatibility_test.go` +- Modify: `main.go:3-23` +- Modify: `pkg/cmd/disablessh/localkeys.go:3-20,117-208` +- Modify: `pkg/cmd/disablessh/localkeys_test.go:120-278` +- Modify: `pkg/sudo/sudo_test.go:34` + +**Interfaces:** + +- Consumes: Task 1's direct `newSystemLocalKeyCleaner()` dependency. +- Produces: ordinary `main()` startup with no command-specific pre-dispatch; `localkeys.go` exposes no helper token, privileged runner, or same-binary re-execution path. + +- [ ] **Step 1: Add a failing process-boundary regression test** + +Add this test beside the existing CLI compatibility tests: + +```go +func Test_DisableSSHCleanupHelperIsNotAnEntrypoint(t *testing.T) { + cmd := exec.Command("go", "run", brevCLIPath, "__brev-disable-ssh-cleanup") + output, err := cmd.CombinedOutput() + require.Error(t, err) + assert.Contains(t, string(output), "unknown command") + assert.NotContains(t, string(output), "privileged Brev key cleanup") + assert.NotContains(t, string(output), "local cleanup is only supported on Linux") +} +``` + +- [ ] **Step 2: Run the process test and observe the special-entrypoint failure** + +Run: + +```bash +go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 +``` + +Expected: FAIL because current `main.go` intercepts the token before Cobra and reports a privileged-helper error instead of `unknown command`. + +- [ ] **Step 3: Restore generic `main.go` startup** + +Delete the `context`, `fmt`, and `pkg/cmd/disablessh` imports and the complete `RunLocalKeyCleanupHelper` branch. The resulting file starts as: + +```go +package main + +import ( + "os" + + "github.com/brevdev/brev-cli/pkg/analytics" + "github.com/brevdev/brev-cli/pkg/cmd" + "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" + "github.com/brevdev/brev-cli/pkg/errors" +) + +func main() { + done := errors.GetDefaultErrorReporter().Setup() + defer done() + defer analytics.Close() + command := cmd.NewDefaultBrevCommand() + + if err := command.Execute(); err != nil { + analytics.CaptureCommandError() + cmderrors.DisplayAndHandleError(err) + done() + os.Exit(1) //nolint:gocritic // manually call done + } +} +``` + +- [ ] **Step 4: Delete the helper protocol and its unit tests** + +In `localkeys.go`, delete: + +- `cleanupHelperArg`. +- `privilegedCommandRunner`, `execPrivilegedCommandRunner`, and its `Output` method. +- `privilegedLocalKeyCleaner` and `newPrivilegedLocalKeyCleaner`. +- `RunLocalKeyCleanupHelper` and `runLocalKeyCleanupHelper`. + +The file must end immediately after: + +```go +func newSystemLocalKeyCleaner() localKeyCleaner { + return systemLocalKeyCleaner{ + listAccounts: listLocalAccounts, + cleanAccount: cleanLocalAccount, + } +} +``` + +Reduce its imports to `bytes`, `context`, `fmt`, `path`, `register`, and `breverrors`. + +In `localkeys_test.go`, delete `fakeLocalKeyCleaner`, both privileged-runner fake types, all `TestPrivilegedLocalKeyCleaner_*` tests, `TestExecPrivilegedCommandRunner_IncludesStderrOnFailure`, and all `TestRunLocalKeyCleanupHelper_*` tests. Keep the parser, marker-filter, and `TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors` coverage unchanged. + +In `pkg/sudo/sudo_test.go`, change only the fixture reason passed to `gater.Gate`: + +```go +err = gater.Gate(terminal.New(), sudoTestConfirmer{}, "Privileged test operation", true) +``` + +Do not revert or otherwise change the generic non-interactive sudo failure assertion. + +- [ ] **Step 5: Format and verify the helper is absent at source and process boundaries** + +Run: + +```bash +gofmt -w main.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/integration/cli_output_compatibility_test.go pkg/sudo/sudo_test.go +go test ./pkg/cmd/disablessh -count=1 +go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 +go test ./pkg/sudo -count=1 +rg -n 'RunLocalKeyCleanupHelper|newPrivilegedLocalKeyCleaner|privilegedLocalKeyCleaner|__brev-disable-ssh-cleanup' main.go pkg/cmd/disablessh +``` + +Expected: all three test commands PASS. The final `rg` prints no matches and exits 1; that no-match result is success for this verification step. + +- [ ] **Step 6: Commit the entrypoint cleanup** + +```bash +git add main.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/integration/cli_output_compatibility_test.go pkg/sudo/sudo_test.go +git commit -m "refactor: remove disable-ssh helper mode" +``` + +--- + +### Task 4: Align Documentation and Run Final Verification + +**Files:** + +- Modify: `docs/BYON.md:46-65` +- Modify: `.agents/skills/brev-cli/SKILL.md:205-230` +- Modify: `.agents/skills/brev-cli/reference/commands.md:555-566` +- Modify: `CHANGELOG.md:16-19` + +**Interfaces:** + +- Consumes: the completed command behavior from Tasks 1-3. +- Produces: one consistent user contract across BYON docs, bundled agent guidance, command reference, and release notes. + +- [ ] **Step 1: Prove the checked-in docs still describe the retired helper** + +Run: + +```bash +rg -n 'privileged root sweep|privileged.*sweep|revokes each exact active.*then' docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md CHANGELOG.md +``` + +Expected: matches in `docs/BYON.md` and `.agents/skills/brev-cli/reference/commands.md` demonstrate stale backend-first/automatic-elevation copy. + +- [ ] **Step 2: Replace the user-facing disable description** + +Use this substance everywhere, shortening only to fit the surrounding document: + +```text +`disable-ssh` is node-wide. After confirmation it first attempts to remove +Brev-tagged public keys from every local account accessible at the current +privilege level, then removes every remaining backend SSH access record. A +non-root run warns that public-key cleanup may be incomplete. The command still +attempts remote cleanup and exits nonzero if either obligation is incomplete; +rerun `sudo brev disable-ssh` to retry the local sweep with root access. + +Retries are safe: files without Brev markers are not rewritten and backend +records already removed are not revoked again. The command leaves ports, +`sshd`, active sessions, network membership, the backend node, and local +registration unchanged. +``` + +In `CHANGELOG.md` under `### Changed`, add: + +```markdown +- `brev disable-ssh` now performs best-effort key cleanup at the caller's privilege level, reports incomplete local or remote cleanup as an error, and recommends an explicit `sudo brev disable-ssh` retry instead of automatically elevating. +``` + +Do not claim that root guarantees success; immutable files, read-only filesystems, malformed account data, and races still surface as nonzero errors. + +- [ ] **Step 3: Verify documentation terminology and formatting** + +Run: + +```bash +rg -n 'privileged root sweep|__brev-disable-ssh-cleanup|automatically elevat' docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md +git diff --check +``` + +Expected: the first command prints no matches and exits 1. `git diff --check` exits 0. + +- [ ] **Step 4: Run focused behavior and race verification** + +Run: + +```bash +go test ./pkg/cmd/disablessh -count=1 +go test -race ./pkg/cmd/disablessh -count=1 +go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 +go test ./pkg/cmd ./pkg/cmd/register ./pkg/cmd/enablessh ./pkg/cmd/deregister ./pkg/sudo -count=1 +``` + +Expected: all commands PASS. The race run preserves the descriptor-safe account-sweep coverage while exercising the new orchestration. + +- [ ] **Step 5: Verify root and platform builds plus scoped lint** + +Run on the macOS development host: + +```bash +go build . +go test -c -o /tmp/brev-disablessh-darwin.test ./pkg/cmd/disablessh +golangci-lint run . ./pkg/cmd/... ./pkg/sudo/... +``` + +Expected: all commands exit 0 and lint reports `0 issues`. + +When a Linux amd64 runner or cached Go 1.25 container is available, also run: + +```bash +docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src golang:1.25 sh -lc 'go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh && go build -o /tmp/brev-cli-linux . && go test -race ./pkg/cmd/disablessh -run "^Test(SystemAuthorizedKeysCleaner|ReplaceAuthorizedKeys)" -count=1' +``` + +Expected: Linux test binary and CLI build succeed; the focused secure-rewrite race suite passes. + +- [ ] **Step 6: Attempt the repository-wide suite and classify only known baselines** + +Run: + +```bash +go test ./... +``` + +Expected: attempt the full suite. If the known untouched macOS baselines recur, record them separately: + +- `e2etest/setup`: hard-coded `/home/ubuntu/brev-cli`. +- `pkg/ssh`: unavailable JetBrains Gateway path followed by the existing nil panic. +- `pkg/store`: Windows/WSL expectations on Darwin. + +Any new failure in `main`, `pkg/integration`, `pkg/cmd/disablessh`, `pkg/cmd`, `pkg/cmd/register`, `pkg/cmd/enablessh`, `pkg/cmd/deregister`, or `pkg/sudo` blocks completion. + +- [ ] **Step 7: Review the final diff and commit docs** + +Run: + +```bash +git diff --check +git status --short +git diff --stat +git diff +``` + +Confirm the final diff contains no `main.go` feature dispatch, helper token, sudo gate, automatic elevation, node/port removal, NetBird uninstall, registration deletion, or unrelated edits. + +Then commit: + +```bash +git add docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md CHANGELOG.md +git commit -m "docs: explain disable-ssh sudo retry" +``` From bca2d2dab0d659fcf97ae982b7f32363ea22c3a1 Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 14 Aug 2026 15:25:08 -0700 Subject: [PATCH 22/23] refactor: simplify disable-ssh revocation --- .agents/skills/brev-cli/SKILL.md | 13 +- .agents/skills/brev-cli/reference/commands.md | 23 +- CHANGELOG.md | 1 + docs/BYON.md | 26 +- .../2026-08-07-byon-network-ssh-separation.md | 8 +- ...6-08-11-disable-ssh-best-effort-cleanup.md | 948 ------------------ ...8-07-byon-network-ssh-separation-design.md | 214 ++-- main.go | 12 - pkg/cmd/deregister/deregister.go | 10 +- pkg/cmd/deregister/deregister_test.go | 6 +- pkg/cmd/disablessh/disablessh.go | 91 +- pkg/cmd/disablessh/disablessh_test.go | 457 +++------ pkg/cmd/disablessh/localkeys.go | 208 ---- pkg/cmd/disablessh/localkeys_linux.go | 531 ---------- pkg/cmd/disablessh/localkeys_linux_test.go | 462 --------- pkg/cmd/disablessh/localkeys_test.go | 278 ----- pkg/cmd/disablessh/localkeys_unsupported.go | 16 - pkg/cmd/disablessh/testdata/.gitattributes | 2 - .../disablessh/testdata/authorized_keys.after | 3 - .../testdata/authorized_keys.before | 5 - pkg/cmd/disablessh/testdata/passwd.txt | 5 - pkg/cmd/enablessh/enablessh_test.go | 205 ---- pkg/cmd/register/sshkeys.go | 153 --- pkg/cmd/register/sshkeys_test.go | 152 --- pkg/sudo/sudo_test.go | 2 +- 25 files changed, 283 insertions(+), 3548 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md delete mode 100644 pkg/cmd/disablessh/localkeys.go delete mode 100644 pkg/cmd/disablessh/localkeys_linux.go delete mode 100644 pkg/cmd/disablessh/localkeys_linux_test.go delete mode 100644 pkg/cmd/disablessh/localkeys_test.go delete mode 100644 pkg/cmd/disablessh/localkeys_unsupported.go delete mode 100644 pkg/cmd/disablessh/testdata/.gitattributes delete mode 100644 pkg/cmd/disablessh/testdata/authorized_keys.after delete mode 100644 pkg/cmd/disablessh/testdata/authorized_keys.before delete mode 100644 pkg/cmd/disablessh/testdata/passwd.txt diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index cfb30305..5766acb6 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -215,7 +215,7 @@ brev join brev enable-ssh brev grant-ssh -# Retire the node completely. +# Explicitly revoke tracked SSH grants, then retire membership. brev disable-ssh brev leave ``` @@ -223,11 +223,12 @@ brev leave `brev register` and `brev deregister` are deprecated aliases for `join` and `leave`; they warn when executed. `enable-ssh` requires an existing join and can reconnect its tunnel, but never joins a network. Use `grant-ssh` and -`revoke-ssh` for individual collaborators. `disable-ssh` removes all -Brev-managed SSH access across the node without closing ports, stopping `sshd`, -ending active sessions, or leaving the network. `leave` removes membership but -leaves Brev-managed keys on the host; run `disable-ssh` first when removing -those keys is intended. +`revoke-ssh` for individual collaborators. `disable-ssh` best-effort revokes +every backend-tracked SSH grant across the node, continuing after individual +failures and revoking the invoking Brev user's own access last. It does not +modify `authorized_keys`, close ports, stop `sshd`, end active sessions, or +leave the network. `leave` removes membership without running that per-grant +revocation flow or cleaning up local keys. ### Instance Management ```bash diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index 18d065be..86de29d0 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -510,7 +510,7 @@ brev join brev enable-ssh brev grant-ssh -# Remove Brev-managed SSH credentials before retiring network membership. +# Explicitly revoke tracked SSH grants before retiring network membership. brev disable-ssh brev leave ``` @@ -554,16 +554,18 @@ or `disable-ssh` as collaborator-management commands. ### brev disable-ssh -Remove all Brev-managed SSH credentials from the joined node. +Revoke all backend-tracked Brev SSH grants from the joined node. ```bash brev disable-ssh [--approve] ``` -This node-wide operation revokes each exact active backend access tuple, then -runs a privileged root sweep to remove Brev-tagged local keys. It leaves -existing ports allocated, leaves `sshd` running, does not forcibly terminate -active SSH sessions, and does not remove membership or the backend node. +This node-wide operation makes a best-effort attempt to revoke each exact active +backend access tuple. It continues after individual failures, reports an error +when any tuple remains, and revokes the invoking Brev user's own access last. It +does not inspect or modify local `authorized_keys` files. It leaves existing +ports allocated, leaves `sshd` running, does not forcibly terminate active SSH +sessions, and does not remove membership or the backend node. ### brev leave / brev deregister @@ -573,10 +575,11 @@ Remove Brev network membership from the device. brev leave [--approve] ``` -`leave` removes the backend node, VPN route, and local registration. It does -not revoke grants or remove host keys from `authorized_keys`; run -`brev disable-ssh` first for complete retirement. `deregister` is a deprecated -alias that warns on execution. +`leave` removes the backend node, VPN route, and local registration. It does not +run `disable-ssh`'s per-grant revocation flow or modify local `authorized_keys` +files. Run `brev disable-ssh` first when explicit best-effort revocation of +tracked grants is desired. `deregister` is a deprecated alias that warns on +execution. `leave` continues to uninstall NetBird even if it was installed before Brev. Install-ownership tracking is a follow-up, so ensure that removal is intended. diff --git a/CHANGELOG.md b/CHANGELOG.md index 733e1570..55bf7a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `brev join` no longer enables SSH. `brev enable-ssh` requires an existing joined membership and reconnects its tunnel when needed. +- `brev disable-ssh` best-effort revokes all backend-tracked SSH grants, revokes the invoking user's own access last, and no longer sweeps local `authorized_keys` files. ### Deprecated diff --git a/docs/BYON.md b/docs/BYON.md index 9c6f9cc2..c9807a82 100644 --- a/docs/BYON.md +++ b/docs/BYON.md @@ -41,28 +41,30 @@ brev revoke-ssh ``` These commands manage individual collaborator access tuples. They are not part -of `join`, `enable-ssh`, or the node-wide cleanup command. +of `join`, `enable-ssh`, or the node-wide revocation command. -## Retire a node completely +## Retire Brev access and membership -For a complete retirement, remove Brev-managed SSH credentials before leaving -the network: +To explicitly revoke Brev-tracked SSH grants before leaving the network: ```bash brev disable-ssh brev leave ``` -`disable-ssh` is node-wide. It revokes each exact active Brev SSH access tuple, -then uses a privileged root sweep to remove Brev-tagged keys from local accounts. -It leaves existing ports allocated, leaves `sshd` running, does not forcibly -terminate active SSH sessions, and does not change network membership. +`disable-ssh` is node-wide. It makes a best-effort attempt to revoke every +backend-tracked Brev SSH access tuple, continuing after individual failures and +revoking the invoking Brev user's own access last. It returns an error if any +revocation fails so the remaining records can be retried. It does not inspect or +modify local `authorized_keys` files. It leaves existing ports allocated, leaves +`sshd` running, does not forcibly terminate active SSH sessions, and does not +change network membership. `leave` removes the backend node, Brev VPN route, and local registration. It -deliberately does not revoke SSH grants or remove keys already stored in -`authorized_keys`; use `disable-ssh` first when those credentials should be -removed. `brev deregister` is a deprecated alias for `leave` and warns when -executed. +does not run the per-grant `disable-ssh` flow or modify local `authorized_keys` +files. Run `disable-ssh` first when explicit best-effort revocation of tracked +grants is desired. `brev deregister` is a deprecated alias for `leave` and warns +when executed. `leave` preserves the existing behavior of uninstalling NetBird even when NetBird was installed before Brev. Tracking whether Brev owns that installation diff --git a/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md b/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md index 01b897ab..17945c0b 100644 --- a/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md +++ b/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md @@ -1,6 +1,12 @@ # BYON Network and SSH Separation Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **Archived; do not execute.** This plan records the original implementation. +> Its `disable-ssh` local-key cleanup, privileged-helper, sudo, and fixture-heavy +> testing sections are superseded by the +> [living design spec](../specs/2026-08-07-byon-network-ssh-separation-design.md). +> The current contract revokes backend-tracked grants only and attempts the +> invoking Brev user's records last. The remaining steps are retained solely as +> historical context. **Goal:** Make `join`/`leave` own BYON NetBird membership, make `enable-ssh`/`disable-ssh` own Brev-managed SSH credentials, and retain `register`/`deregister` only as deprecated aliases. diff --git a/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md b/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md deleted file mode 100644 index a68c1207..00000000 --- a/docs/superpowers/plans/2026-08-11-disable-ssh-best-effort-cleanup.md +++ /dev/null @@ -1,948 +0,0 @@ -# Disable SSH Best-Effort Cleanup Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `brev disable-ssh` attempt local Brev-key cleanup directly at the caller's privilege level before backend work, continue through independent failures, and support an explicit, idempotent `sudo brev disable-ssh` retry without a privileged helper mode. - -**Architecture:** Keep the descriptor-safe Linux account sweep unchanged, but invoke it directly from the command with the current effective UID. Use a command-local Cobra persistent hook to defer the root command's normal pre-run until after the local sweep, then split the confirmed operation into two independently attempted obligations—local tagged-key cleanup and remote SSH-record removal—and join their classified errors at the end. Remove the same-binary sudo re-execution path and restore `main.go` to generic CLI startup only. - -**Tech Stack:** Go 1.25, Cobra, ConnectRPC/protobuf, the repository's `terminal` and `errors` packages, `os.Geteuid`, and existing Linux `golang.org/x/sys/unix` key-rewrite code. - -## Global Constraints - -- Implement only in `/Users/pratpatel/code/brev-cli-byon-network-join` on branch `codex/byon-network-join`. -- Treat `docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md` at commit `d1591559` or later as the approved behavioral contract. -- This follow-up supersedes only the privileged-helper and backend-first `disable-ssh` portions of `docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md`; do not change the already-implemented `join`, `enable-ssh`, `leave`, alias, or SSH-grant boundaries. -- `disable-ssh` remains Linux-only and still requires the global `/etc/brev/device_registration.json` registration before confirmation or mutation. -- Do not automatically invoke `sudo`, re-execute the Brev binary, add a hidden helper argument, or add command-specific dispatch to `main.go`. -- When `os.Geteuid() != 0`, stderr must include: `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.` -- `--approve` skips only confirmation; it does not suppress the non-root, node-wide, or active-session warnings. -- After confirmation, local key cleanup runs before `GetCurrentUser`, `GetNode`, NetBird reconnection, or `RevokeNodeSSHAccess`. -- The root Cobra `PersistentPreRunE` must not execute automatically before `disable-ssh`. Invoke it explicitly as the first remote-phase operation after local cleanup so version, feature-flag, user-home, or `--user` failures are classified as remote cleanup errors. -- Local cleanup and remote-record cleanup are independent obligations. Attempt remote cleanup after a local error, retain both causes, and return nonzero if either obligation is incomplete. -- Classify local failures with `failed to clean up public keys` and auth, node lookup, tunnel, or revocation failures with `failed to remove remote SSH access records`. -- Never print the overall `SSH access disabled` success line when either obligation fails. Partial key counts may be printed as progress, not success. -- Preserve idempotency: marker-free files are not rewritten; each run fetches a fresh `SSHAccess` snapshot; absent grants are not re-revoked; registration and membership are never removed. -- Do not change the secure Linux file traversal/rewrite implementation in `localkeys_linux.go` or its race, symlink, FIFO, ownership, and mode guarantees. -- Keep the generic `pkg/sudo` non-interactive fix. Other commands still depend on it. -- Use TDD for every behavior change: add the focused failing test, observe the expected failure, implement the smallest change, rerun, and commit. -- Run `gofmt` on touched Go files. Keep errors wrapped with `%w` and preserve causes for `errors.Is`/`require.ErrorIs`. - -## File Map - -### Modify - -- `pkg/cmd/disablessh/disablessh.go`: current-EUID warning, direct cleaner dependency, cleanup-first state machine, remote cleanup helper, joined error classification, and registration-based confirmation output. -- `pkg/cmd/disablessh/disablessh_test.go`: warning/root behavior, cleanup-first ordering, independent failure continuation, exact error labels, cancellation, retry idempotency, and removal of sudo-gater expectations. -- `pkg/cmd/disablessh/localkeys.go`: retain only account parsing, Brev-line filtering, account sweeping, and `newSystemLocalKeyCleaner`; delete privileged process/helper protocol. -- `pkg/cmd/disablessh/localkeys_test.go`: retain parser/filter/sweep tests; delete privileged runner and helper-mode tests. -- `main.go`: remove the `disablessh` early-dispatch branch and its command-specific imports. -- `pkg/integration/cli_output_compatibility_test.go`: prove the retired helper token reaches normal Cobra handling rather than a special main entrypoint. -- `pkg/sudo/sudo_test.go`: replace the stale disable-specific fixture reason with generic sudo-test wording; keep the generic non-interactive behavior unchanged. -- `docs/BYON.md`: document current-EUID cleanup, non-root failure behavior, error aggregation, and sudo retry. -- `.agents/skills/brev-cli/SKILL.md`: keep the bundled skill's BYON guidance aligned. -- `.agents/skills/brev-cli/reference/commands.md`: replace the privileged-root-sweep description with the explicit retry contract. -- `CHANGELOG.md`: call out the changed `disable-ssh` elevation and retry behavior. - -### Deliberately Unchanged - -- `pkg/cmd/disablessh/localkeys_linux.go` and `localkeys_linux_test.go`: the secure rewrite and no-marker no-rewrite behavior already satisfy the new contract. -- `pkg/cmd/disablessh/localkeys_unsupported.go`: the direct cleaner already has a non-Linux companion and the command rejects non-Linux platforms first. -- `pkg/sudo`: remains available to `join` and `leave`; `disable-ssh` simply stops depending on it. -- `pkg/cmd/cmd.go`: continues to register the ordinary top-level `disable-ssh` Cobra command exactly once. - ---- - -### Task 1: Stop Automatically Elevating `disable-ssh` - -**Files:** - -- Modify: `pkg/cmd/disablessh/disablessh.go:4-50,119-133` -- Modify: `pkg/cmd/disablessh/disablessh_test.go:92-118,232-278,313-350,515-533` - -**Interfaces:** - -- Consumes: existing `localKeyCleaner.RemoveBrevKeys(context.Context) (KeyCleanupResult, error)` and `newSystemLocalKeyCleaner()`. -- Produces: `disableSSHDeps.geteuid func() int`; production defaults `geteuid` to `os.Geteuid` and `keyCleaner` to `newSystemLocalKeyCleaner()`. - -- [ ] **Step 1: Add failing direct-cleaner and warning tests** - -Delete `disableSSHTestGater`, remove `gater` from `disableSSHTestHarness`, add an effective UID to the harness, and make the injected getter mutable per test. Add a confirmation hook so warning order is observable: - -```go -type disableSSHTestConfirmer struct { - events *[]string - answer bool - calls int - labels []string - beforeConfirm func() -} - -func (c *disableSSHTestConfirmer) ConfirmYesNo(label string) bool { - if c.beforeConfirm != nil { - c.beforeConfirm() - } - c.calls++ - c.labels = append(c.labels, label) - recordDisableSSHEvent(c.events, "confirm") - return c.answer -} - -type disableSSHTestHarness struct { - events []string - store *disableSSHTestStore - registrations *disableSSHTestRegistrationStore - confirmer *disableSSHTestConfirmer - tunnel *disableSSHTestTunnel - cleaner *disableSSHTestKeyCleaner - client *disableSSHRecordingClient - deps disableSSHDeps - euid int -} - -func newDisableSSHTestHarness(accesses ...*nodev1.SSHAccess) *disableSSHTestHarness { - h := &disableSSHTestHarness{euid: 0} - h.store = &disableSSHTestStore{events: &h.events} - h.registrations = &disableSSHTestRegistrationStore{ - events: &h.events, - exists: true, - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "node_123", - DisplayName: "owned-node", - OrgID: "org_123", - OrgName: "owned-org", - }, - } - h.confirmer = &disableSSHTestConfirmer{events: &h.events, answer: true} - h.tunnel = &disableSSHTestTunnel{events: &h.events} - h.cleaner = &disableSSHTestKeyCleaner{ - events: &h.events, - result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}, - } - h.client = &disableSSHRecordingClient{ - events: &h.events, - node: &nodev1.ExternalNode{ExternalNodeId: "node_123", Name: "owned-node", SshAccess: accesses}, - revokeErrors: make(map[int]error), - } - h.deps = disableSSHDeps{ - platform: &disableSSHTestPlatform{compatible: true, events: &h.events}, - confirmer: h.confirmer, - geteuid: func() int { return h.euid }, - tunnel: h.tunnel, - nodeClients: disableSSHTestNodeClientFactory{client: h.client}, - registrationStore: h.registrations, - keyCleaner: h.cleaner, - } - return h -} -``` - -Add these tests: - -```go -func TestDefaultDisableSSHDeps_UsesDirectSystemCleaner(t *testing.T) { - deps := defaultDisableSSHDeps() - require.NotNil(t, deps.geteuid) - _, ok := deps.keyCleaner.(systemLocalKeyCleaner) - require.True(t, ok) -} - -func TestRunDisableSSH_NonRootWarnsAndApproveDoesNotSuppressWarnings(t *testing.T) { - h := newDisableSSHTestHarness() - h.euid = 1000 - - _, stderr, err := h.run(t, true) - require.NoError(t, err) - require.Zero(t, h.confirmer.calls) - require.Contains(t, stderr, "Warning: not running as root; public key cleanup may be incomplete.") - require.Contains(t, stderr, `Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) - require.Contains(t, stderr, "node-wide") - require.Contains(t, stderr, "active SSH sessions are not forcibly terminated") -} - -func TestRunDisableSSH_RootDoesNotPrintNonRootWarning(t *testing.T) { - h := newDisableSSHTestHarness() - h.euid = 0 - - _, stderr, err := h.run(t, true) - require.NoError(t, err) - require.NotContains(t, stderr, "not running as root") -} - -func TestRunDisableSSH_NonRootWarningIsWrittenBeforeConfirmation(t *testing.T) { - h := newDisableSSHTestHarness() - h.euid = 1000 - var warnings bytes.Buffer - h.confirmer.beforeConfirm = func() { - require.Contains(t, warnings.String(), "not running as root") - } - - _, err := captureDisableSSHStdout(t, func(term *terminal.Terminal) error { - return runDisableSSH(context.Background(), term, &warnings, h.store, h.deps, false) - }) - require.NoError(t, err) -} -``` - -Apply these exact legacy-test changes so the package compiles after deleting the fake gater: - -- Delete every `require.Zero(t, h.gater.calls)` and `h.gater.reasons` assertion. -- Rename `TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup` to `TestRunDisableSSH_CancelStopsBeforeTunnelRevocationAndCleanup`. -- In `TestRunDisableSSH_ConnectsBeforeFirstRevocation`, temporarily require `tunnel`, `revoke:user_1`, then `cleanup`; Task 2 will move cleanup first. -- In `TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys`, require only `cleanup` for the mutation subsequence. -- Rename `TestRunDisableSSH_StateMachineOrdersPreflightConfirmationAndSudo` to `TestRunDisableSSH_StateMachineHasNoAutomaticElevation` and require this exact Task 1 sequence: - - ```go - require.Equal(t, []string{ - "platform", - "registration-exists", - "registration-load", - "auth", - "get-node", - "confirm", - "tunnel", - "revoke:user_1", - "cleanup", - }, h.events) - ``` - -At this task boundary, backend-first ordering otherwise remains unchanged. - -- [ ] **Step 2: Run the focused tests and observe the expected failure** - -Run: - -```bash -go test ./pkg/cmd/disablessh -run 'Test(DefaultDisableSSHDeps_UsesDirectSystemCleaner|RunDisableSSH_(NonRootWarnsAndApproveDoesNotSuppressWarnings|RootDoesNotPrintNonRootWarning|NonRootWarningIsWrittenBeforeConfirmation))' -count=1 -``` - -Expected: FAIL because `disableSSHDeps` has no `geteuid`, defaults to `newPrivilegedLocalKeyCleaner`, and does not print the non-root warning. - -- [ ] **Step 3: Replace the sudo gate with a direct current-EUID dependency** - -In `disablessh.go`, remove the `pkg/sudo` import, add `os`, and make the dependency shape exactly: - -```go -type disableSSHDeps struct { - platform externalnode.PlatformChecker - confirmer terminal.Confirmer - geteuid func() int - tunnel register.NetBirdConnector - nodeClients externalnode.NodeClientFactory - registrationStore register.RegistrationStore - keyCleaner localKeyCleaner -} - -func defaultDisableSSHDeps() disableSSHDeps { - return disableSSHDeps{ - platform: register.LinuxPlatform{}, - confirmer: register.TerminalPrompter{}, - geteuid: os.Geteuid, - tunnel: register.Netbird{}, - nodeClients: register.DefaultNodeClientFactory{}, - registrationStore: register.NewFileRegistrationStore(), - keyCleaner: newSystemLocalKeyCleaner(), - } -} -``` - -After normalizing a nil warning writer and before confirmation, add: - -```go -if deps.geteuid() != 0 { - _, _ = fmt.Fprintln(warnings, `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) -} -``` - -Delete the entire `deps.gater.Gate(...)` block. Do not add any replacement sudo prompt or subprocess. - -- [ ] **Step 4: Format and run the full command package** - -Run: - -```bash -gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go -go test ./pkg/cmd/disablessh -count=1 -``` - -Expected: PASS. Existing backend-first behavior remains green while automatic elevation and its test double are gone. - -- [ ] **Step 5: Commit the privilege-boundary change** - -```bash -git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go -git commit -m "refactor: stop auto-elevating disable-ssh" -``` - ---- - -### Task 2: Attempt Local and Remote Cleanup Independently - -**Files:** - -- Modify: `pkg/cmd/disablessh/disablessh.go:75-198` -- Modify: `pkg/cmd/disablessh/disablessh_test.go:313-551` - -**Interfaces:** - -- Consumes: `disableSSHDeps` from Task 1, `cmdcontext.InvokeParentPersistentPreRun`, `register.FetchRegisteredNode`, `revokeSSHAccesses`, and `breverrors.Join`. -- Produces: `disableSSHDeps.prepareRemote func() error` and `removeRemoteSSHAccessRecords(context.Context, DisableSSHStore, disableSSHDeps, *register.DeviceRegistration) error`; `runDisableSSH` always invokes the local cleaner before this helper after confirmation. - -- [ ] **Step 1: Replace backend-first tests with cleanup-first failure tests** - -Add or replace tests with the following behavior: - -```go -func TestRunDisableSSH_CleanupRunsBeforeAuthenticationAndAuthFailureIsRemoteError(t *testing.T) { - authErr := errors.New("authentication failed") - h := newDisableSSHTestHarness() - h.store.currentUserErr = authErr - - stdout, _, err := h.run(t, true) - require.ErrorIs(t, err, authErr) - require.Contains(t, err.Error(), "failed to remove remote SSH access records") - require.NotContains(t, err.Error(), "failed to clean up public keys") - requireOrderedSubsequence(t, h.events, "registration-load", "cleanup", "auth") - require.NotContains(t, stdout, "SSH access disabled") -} - -func TestNewCmdDisableSSH_ParentPreRunFailureOccursAfterLocalCleanup(t *testing.T) { - parentErr := errors.New("root pre-run failed") - h := newDisableSSHTestHarness() - root := &cobra.Command{ - Use: "brev", - PersistentPreRunE: func(*cobra.Command, []string) error { - recordDisableSSHEvent(&h.events, "parent-pre-run") - return parentErr - }, - } - - _, err := captureDisableSSHStdout(t, func(term *terminal.Terminal) error { - root.AddCommand(newCmdDisableSSH(term, h.store, h.deps)) - root.SetArgs([]string{"disable-ssh", "--approve"}) - root.SetErr(io.Discard) - return root.Execute() - }) - require.ErrorIs(t, err, parentErr) - require.Contains(t, err.Error(), "failed to remove remote SSH access records") - requireOrderedSubsequence(t, h.events, "cleanup", "parent-pre-run") - require.Equal(t, []string{"parent-pre-run"}, filterDisableSSHEvents(h.events, "parent-pre-run")) - require.Equal(t, 1, h.cleaner.calls) - require.Zero(t, h.store.currentUserCalls) -} - -func TestRunDisableSSH_LocalFailureStillRemovesRemoteRecords(t *testing.T) { - cleanupErr := errors.New("alice authorized_keys is not writable") - h := newDisableSSHTestHarness(testSSHAccess("user_1", "alice", "port_1")) - h.cleaner.result = KeyCleanupResult{AccountsScanned: 2, AccountsChanged: 1, KeysRemoved: 1} - h.cleaner.err = cleanupErr - - stdout, _, err := h.run(t, true) - require.ErrorIs(t, err, cleanupErr) - require.Contains(t, err.Error(), "failed to clean up public keys") - require.NotContains(t, err.Error(), "failed to remove remote SSH access records") - require.Len(t, h.client.revokeRequests, 1) - requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node", "tunnel", "revoke:user_1") - require.Contains(t, stdout, "1 keys removed") - require.NotContains(t, stdout, "SSH access disabled") - require.Zero(t, h.client.removeNodeCalls) - require.Zero(t, h.client.closePortCalls) - require.Zero(t, h.tunnel.uninstallCalls) - require.Zero(t, h.registrations.deleteCalls) -} - -func TestRunDisableSSH_LocalAndRemoteFailuresAreJoined(t *testing.T) { - cleanupErr := errors.New("local cleanup failed") - revokeErr := errors.New("remote revoke failed") - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - h.cleaner.err = cleanupErr - h.client.revokeErrors[0] = revokeErr - - stdout, _, err := h.run(t, true) - require.ErrorIs(t, err, cleanupErr) - require.ErrorIs(t, err, revokeErr) - require.Contains(t, err.Error(), "failed to clean up public keys") - require.Contains(t, err.Error(), "failed to remove remote SSH access records") - require.Equal(t, 1, h.cleaner.calls) - require.Len(t, h.client.revokeRequests, 1) - require.NotContains(t, stdout, "SSH access disabled") -} - -func TestRunDisableSSH_NonRootFailureThenRootRetrySkipsRemovedRecords(t *testing.T) { - cleanupErr := errors.New("another account is not writable") - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - h.euid = 1000 - h.cleaner.err = cleanupErr - - _, _, err := h.run(t, true) - require.ErrorIs(t, err, cleanupErr) - require.Len(t, h.client.revokeRequests, 1) - require.Equal(t, 1, h.tunnel.ensureCalls) - - h.euid = 0 - h.cleaner.err = nil - h.client.node.SshAccess = nil - h.cleaner.result = KeyCleanupResult{} - _, _, err = h.run(t, true) - require.NoError(t, err) - require.Len(t, h.client.revokeRequests, 1, "second run must not re-revoke an absent record") - require.Equal(t, 1, h.tunnel.ensureCalls, "second run with no records must skip the tunnel") - require.Equal(t, 2, h.cleaner.calls, "each run rechecks local state idempotently") -} -``` - -Update the cancellation test to require exactly: - -```go -require.Equal(t, []string{"platform", "registration-exists", "registration-load", "confirm"}, h.events) -require.Zero(t, h.cleaner.calls) -require.Zero(t, h.store.currentUserCalls) -``` - -Replace `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts` with a registration-only preflight assertion: - -```go -func TestRunDisableSSH_ConfirmationOutputUsesLocalRegistration(t *testing.T) { - h := newDisableSSHTestHarness() - h.store.currentUserErr = errors.New("backend unavailable after confirmation") - - stdout, _, err := h.run(t, true) - require.Error(t, err) - require.Contains(t, stdout, "owned-node") - require.Contains(t, stdout, "node_123") - require.NotContains(t, stdout, "SSH grants:") - require.NotContains(t, stdout, "Linux accounts:") -} -``` - -Use these replacements rather than leaving contradictory backend-first tests in the file: - -- Replace `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts` with `TestRunDisableSSH_ConfirmationOutputUsesLocalRegistration`. -- Replace `TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup` with `TestRunDisableSSH_RevocationFailureOccursAfterLocalCleanup`. -- Rename `TestRunDisableSSH_NotFoundRevocationBlocksLocalCleanup` to `TestRunDisableSSH_NotFoundRevocationPreservesCauseAfterLocalCleanup`. -- Rename `TestRunDisableSSH_TunnelFailureStopsBeforeRevocationAndCleanup` to `TestRunDisableSSH_TunnelFailureOccursAfterLocalCleanup`. -- Replace `TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership` with `TestRunDisableSSH_LocalFailureStillRemovesRemoteRecords`; the existing no-membership-mutation test continues to protect node, registration, NetBird, and port boundaries. -- Rename `TestRunDisableSSH_BackendNodeFailureStopsBeforeConfirmationAndMutation` to `TestRunDisableSSH_BackendNodeFailureOccursAfterConfirmationAndCleanup`. - -- [ ] **Step 2: Run the focused state-machine tests and observe RED** - -Run: - -```bash -go test ./pkg/cmd/disablessh -run 'Test(NewCmdDisableSSH_ParentPreRunFailureOccursAfterLocalCleanup|RunDisableSSH_(CleanupRunsBeforeAuthenticationAndAuthFailureIsRemoteError|LocalFailureStillRemovesRemoteRecords|LocalAndRemoteFailuresAreJoined|NonRootFailureThenRootRetrySkipsRemovedRecords|ConfirmationOutputUsesLocalRegistration))' -count=1 -``` - -Expected: FAIL because current code authenticates before confirmation, stops on the first obligation error, prints backend-derived counts, and runs cleanup last. - -- [ ] **Step 3: Extract remote cleanup and implement the two-obligation state machine** - -Add `github.com/brevdev/brev-cli/pkg/cmdcontext` to `disablessh.go`. Extend the dependency struct and defaults with: - -```go -type disableSSHDeps struct { - platform externalnode.PlatformChecker - confirmer terminal.Confirmer - geteuid func() int - prepareRemote func() error - tunnel register.NetBirdConnector - nodeClients externalnode.NodeClientFactory - registrationStore register.RegistrationStore - keyCleaner localKeyCleaner -} - -func defaultDisableSSHDeps() disableSSHDeps { - return disableSSHDeps{ - platform: register.LinuxPlatform{}, - confirmer: register.TerminalPrompter{}, - geteuid: os.Geteuid, - prepareRemote: func() error { return nil }, - tunnel: register.Netbird{}, - nodeClients: register.DefaultNodeClientFactory{}, - registrationStore: register.NewFileRegistrationStore(), - keyCleaner: newSystemLocalKeyCleaner(), - } -} -``` - -Also set `prepareRemote: func() error { return nil }` in `newDisableSSHTestHarness`. Replace `newCmdDisableSSH` with this command-level hook structure; Cobra v1.8.1 executes only the closest persistent pre-run by default, so the child hook prevents the root hook from running before `RunE`: - -```go -func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableSSHDeps) *cobra.Command { - var approveFlag bool - cmd := &cobra.Command{ - Annotations: map[string]string{"configuration": ""}, - Use: "disable-ssh", - DisableFlagsInUseLine: true, - Short: "Disable all Brev-managed SSH access on this node", - Long: "Disable every Brev-managed SSH credential on this joined node without changing Brev network membership or the SSH daemon.", - Example: " brev disable-ssh\n brev disable-ssh --approve", - Args: cobra.NoArgs, - PersistentPreRunE: func(*cobra.Command, []string) error { - // Defer the parent's fallible setup until after local key cleanup. - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - runDeps := deps - runDeps.prepareRemote = func() error { - return cmdcontext.InvokeParentPersistentPreRun(cmd, args) - } - return runDisableSSH(cmd.Context(), t, cmd.ErrOrStderr(), store, runDeps, approveFlag) - }, - } - cmd.Flags().BoolVar(&approveFlag, "approve", false, "skip confirmation prompt (assume yes)") - return cmd -} -``` - -Replace `runDisableSSH` with this complete state machine: - -```go -func runDisableSSH( - ctx context.Context, - t *terminal.Terminal, - warnings io.Writer, - store DisableSSHStore, - deps disableSSHDeps, - skipConfirm bool, -) error { //nolint:funlen // Ordered teardown state machine is intentionally explicit. - if !deps.platform.IsCompatible() { - return fmt.Errorf("brev disable-ssh is only supported on Linux") - } - - exists, err := deps.registrationStore.Exists() - if err != nil { - return fmt.Errorf("check joined-device registration: %w", err) - } - if !exists { - return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) - } - - reg, err := deps.registrationStore.Load() - if err != nil { - return fmt.Errorf("read joined-device registration: %w", err) - } - -if warnings == nil { - warnings = io.Discard -} - -t.Vprint("") -t.Vprint(t.White("══════════════════════════════════════════════════")) -t.Vprint(t.White(" Disabling Brev-managed SSH access")) -t.Vprint(t.White("══════════════════════════════════════════════════")) -t.Vprint("") -t.Vprintf(" Node: %s (%s)\n", reg.DisplayName, reg.ExternalNodeID) -t.Vprint("") - -if deps.geteuid() != 0 { - _, _ = fmt.Fprintln(warnings, `Warning: not running as root; public key cleanup may be incomplete. Re-run "sudo brev disable-ssh" to allow cleanup across all local accounts.`) -} -_, _ = fmt.Fprintln(warnings, "Warning: this is a node-wide operation that removes all Brev-managed SSH credentials on this node.") -_, _ = fmt.Fprintln(warnings, "Warning: active SSH sessions are not forcibly terminated.") - -if !skipConfirm && !deps.confirmer.ConfirmYesNo("Disable all Brev-managed SSH access on this node?") { - t.Vprint("Disable SSH canceled.") - return nil -} - -result, localCleanupErr := deps.keyCleaner.RemoveBrevKeys(ctx) -if localCleanupErr != nil { - localCleanupErr = fmt.Errorf("failed to clean up public keys: %w", localCleanupErr) -} - -remoteCleanupErr := removeRemoteSSHAccessRecords(ctx, store, deps, reg) -if remoteCleanupErr != nil { - remoteCleanupErr = fmt.Errorf("failed to remove remote SSH access records: %w", remoteCleanupErr) -} - -if err := breverrors.Join(localCleanupErr, remoteCleanupErr); err != nil { - t.Vprintf(" Public key cleanup: %d keys removed; %d accounts changed.\n", result.KeysRemoved, result.AccountsChanged) - return fmt.Errorf("disable SSH incomplete: %w", err) -} - -t.Vprintf("%s SSH access disabled: %d keys removed; %d accounts changed.\n", t.Green(" ✓"), result.KeysRemoved, result.AccountsChanged) -return nil -} -``` - -Add the remote helper immediately below `runDisableSSH`: - -```go -func removeRemoteSSHAccessRecords( - ctx context.Context, - store DisableSSHStore, - deps disableSSHDeps, - reg *register.DeviceRegistration, -) error { - if err := deps.prepareRemote(); err != nil { - return fmt.Errorf("prepare Brev command: %w", err) - } - if _, err := store.GetCurrentUser(); err != nil { - return fmt.Errorf("authenticate Brev user: %w", err) - } - - node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, store, reg) - if err != nil { - return fmt.Errorf("fetch registered node: %w", err) - } - accesses := snapshotSSHAccess(node.GetSshAccess()) - if len(accesses) == 0 { - return nil - } - - if err := deps.tunnel.EnsureConnected(ctx); err != nil { - return fmt.Errorf("connect Brev tunnel: %w", err) - } - client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) - if err := revokeSSHAccesses(ctx, client, reg.ExternalNodeID, accesses); err != nil { - return err - } - return nil -} -``` - -Delete `distinctLinuxAccountCount`; the command deliberately no longer authenticates before confirmation merely to render counts. - -- [ ] **Step 4: Update every affected legacy assertion explicitly** - -Use these exact replacements while retaining the existing tuple detail, sequential-call, and no-membership-mutation assertions: - -```go -// TestRunDisableSSH_ConnectsBeforeFirstRevocation -requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node", "tunnel", "revoke:user_1") - -// TestRunDisableSSH_StateMachineHasNoAutomaticElevation -require.Equal(t, []string{ - "platform", - "registration-exists", - "registration-load", - "confirm", - "cleanup", - "auth", - "get-node", - "tunnel", - "revoke:user_1", -}, h.events) - -// TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors -require.ErrorIs(t, err, firstErr) -require.ErrorIs(t, err, middleErr) -require.Contains(t, err.Error(), "failed to remove remote SSH access records") -require.Equal(t, 1, h.cleaner.calls) -require.Len(t, h.client.revokeRequests, 3) - -// Rename to TestRunDisableSSH_NotFoundRevocationPreservesCauseAfterLocalCleanup -require.Error(t, err) -require.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) -require.Contains(t, err.Error(), "failed to remove remote SSH access records") -require.Equal(t, 1, h.cleaner.calls) - -// Rename to TestRunDisableSSH_RevocationFailureOccursAfterLocalCleanup -require.Error(t, err) -require.Contains(t, err.Error(), "failed to remove remote SSH access records") -require.Equal(t, 1, h.cleaner.calls) -require.Len(t, h.client.revokeRequests, 2) - -// TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys -requireOrderedSubsequence(t, h.events, "cleanup", "auth", "get-node") -require.Zero(t, h.tunnel.ensureCalls) -require.Empty(t, h.client.revokeRequests) - -// TestRunDisableSSH_IgnoresNilAccessEntries -require.NotContains(t, stdout, "SSH grants:") -require.Len(t, h.client.revokeRequests, 2) - -// Rename to TestRunDisableSSH_TunnelFailureOccursAfterLocalCleanup -require.ErrorIs(t, err, tunnelErr) -require.Contains(t, err.Error(), "failed to remove remote SSH access records") -require.Equal(t, 1, h.cleaner.calls) -require.Empty(t, h.client.revokeRequests) - -// Rename to TestRunDisableSSH_BackendNodeFailureOccursAfterConfirmationAndCleanup -requireOrderedSubsequence(t, h.events, "confirm", "cleanup", "auth", "get-node") -require.Equal(t, 1, h.cleaner.calls) -require.NotContains(t, stdout, "SSH access disabled") - -// TestRunDisableSSH_SuccessIncludesCleanupCounts -require.NoError(t, err) -require.Contains(t, stdout, "SSH access disabled") -require.Contains(t, stdout, "3 keys removed") -require.Contains(t, stdout, "2 accounts changed") -``` - -- [ ] **Step 5: Run formatting, the full package, and the local idempotency regression** - -Run: - -```bash -gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go -go test ./pkg/cmd/disablessh -count=1 -go test ./pkg/cmd/disablessh -run '^TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors$|^TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes$' -count=1 -``` - -Expected: PASS. The first command suite proves orchestration idempotency; the second preserves per-account continuation and marker-free byte idempotency. - -- [ ] **Step 6: Commit cleanup-first orchestration** - -```bash -git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go -git commit -m "fix: make disable-ssh cleanup retryable" -``` - ---- - -### Task 3: Remove the Privileged Helper Entrypoint - -**Files:** - -- Modify: `pkg/integration/cli_output_compatibility_test.go` -- Modify: `main.go:3-23` -- Modify: `pkg/cmd/disablessh/localkeys.go:3-20,117-208` -- Modify: `pkg/cmd/disablessh/localkeys_test.go:120-278` -- Modify: `pkg/sudo/sudo_test.go:34` - -**Interfaces:** - -- Consumes: Task 1's direct `newSystemLocalKeyCleaner()` dependency. -- Produces: ordinary `main()` startup with no command-specific pre-dispatch; `localkeys.go` exposes no helper token, privileged runner, or same-binary re-execution path. - -- [ ] **Step 1: Add a failing process-boundary regression test** - -Add this test beside the existing CLI compatibility tests: - -```go -func Test_DisableSSHCleanupHelperIsNotAnEntrypoint(t *testing.T) { - cmd := exec.Command("go", "run", brevCLIPath, "__brev-disable-ssh-cleanup") - output, err := cmd.CombinedOutput() - require.Error(t, err) - assert.Contains(t, string(output), "unknown command") - assert.NotContains(t, string(output), "privileged Brev key cleanup") - assert.NotContains(t, string(output), "local cleanup is only supported on Linux") -} -``` - -- [ ] **Step 2: Run the process test and observe the special-entrypoint failure** - -Run: - -```bash -go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 -``` - -Expected: FAIL because current `main.go` intercepts the token before Cobra and reports a privileged-helper error instead of `unknown command`. - -- [ ] **Step 3: Restore generic `main.go` startup** - -Delete the `context`, `fmt`, and `pkg/cmd/disablessh` imports and the complete `RunLocalKeyCleanupHelper` branch. The resulting file starts as: - -```go -package main - -import ( - "os" - - "github.com/brevdev/brev-cli/pkg/analytics" - "github.com/brevdev/brev-cli/pkg/cmd" - "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/errors" -) - -func main() { - done := errors.GetDefaultErrorReporter().Setup() - defer done() - defer analytics.Close() - command := cmd.NewDefaultBrevCommand() - - if err := command.Execute(); err != nil { - analytics.CaptureCommandError() - cmderrors.DisplayAndHandleError(err) - done() - os.Exit(1) //nolint:gocritic // manually call done - } -} -``` - -- [ ] **Step 4: Delete the helper protocol and its unit tests** - -In `localkeys.go`, delete: - -- `cleanupHelperArg`. -- `privilegedCommandRunner`, `execPrivilegedCommandRunner`, and its `Output` method. -- `privilegedLocalKeyCleaner` and `newPrivilegedLocalKeyCleaner`. -- `RunLocalKeyCleanupHelper` and `runLocalKeyCleanupHelper`. - -The file must end immediately after: - -```go -func newSystemLocalKeyCleaner() localKeyCleaner { - return systemLocalKeyCleaner{ - listAccounts: listLocalAccounts, - cleanAccount: cleanLocalAccount, - } -} -``` - -Reduce its imports to `bytes`, `context`, `fmt`, `path`, `register`, and `breverrors`. - -In `localkeys_test.go`, delete `fakeLocalKeyCleaner`, both privileged-runner fake types, all `TestPrivilegedLocalKeyCleaner_*` tests, `TestExecPrivilegedCommandRunner_IncludesStderrOnFailure`, and all `TestRunLocalKeyCleanupHelper_*` tests. Keep the parser, marker-filter, and `TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors` coverage unchanged. - -In `pkg/sudo/sudo_test.go`, change only the fixture reason passed to `gater.Gate`: - -```go -err = gater.Gate(terminal.New(), sudoTestConfirmer{}, "Privileged test operation", true) -``` - -Do not revert or otherwise change the generic non-interactive sudo failure assertion. - -- [ ] **Step 5: Format and verify the helper is absent at source and process boundaries** - -Run: - -```bash -gofmt -w main.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/integration/cli_output_compatibility_test.go pkg/sudo/sudo_test.go -go test ./pkg/cmd/disablessh -count=1 -go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 -go test ./pkg/sudo -count=1 -rg -n 'RunLocalKeyCleanupHelper|newPrivilegedLocalKeyCleaner|privilegedLocalKeyCleaner|__brev-disable-ssh-cleanup' main.go pkg/cmd/disablessh -``` - -Expected: all three test commands PASS. The final `rg` prints no matches and exits 1; that no-match result is success for this verification step. - -- [ ] **Step 6: Commit the entrypoint cleanup** - -```bash -git add main.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/integration/cli_output_compatibility_test.go pkg/sudo/sudo_test.go -git commit -m "refactor: remove disable-ssh helper mode" -``` - ---- - -### Task 4: Align Documentation and Run Final Verification - -**Files:** - -- Modify: `docs/BYON.md:46-65` -- Modify: `.agents/skills/brev-cli/SKILL.md:205-230` -- Modify: `.agents/skills/brev-cli/reference/commands.md:555-566` -- Modify: `CHANGELOG.md:16-19` - -**Interfaces:** - -- Consumes: the completed command behavior from Tasks 1-3. -- Produces: one consistent user contract across BYON docs, bundled agent guidance, command reference, and release notes. - -- [ ] **Step 1: Prove the checked-in docs still describe the retired helper** - -Run: - -```bash -rg -n 'privileged root sweep|privileged.*sweep|revokes each exact active.*then' docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md CHANGELOG.md -``` - -Expected: matches in `docs/BYON.md` and `.agents/skills/brev-cli/reference/commands.md` demonstrate stale backend-first/automatic-elevation copy. - -- [ ] **Step 2: Replace the user-facing disable description** - -Use this substance everywhere, shortening only to fit the surrounding document: - -```text -`disable-ssh` is node-wide. After confirmation it first attempts to remove -Brev-tagged public keys from every local account accessible at the current -privilege level, then removes every remaining backend SSH access record. A -non-root run warns that public-key cleanup may be incomplete. The command still -attempts remote cleanup and exits nonzero if either obligation is incomplete; -rerun `sudo brev disable-ssh` to retry the local sweep with root access. - -Retries are safe: files without Brev markers are not rewritten and backend -records already removed are not revoked again. The command leaves ports, -`sshd`, active sessions, network membership, the backend node, and local -registration unchanged. -``` - -In `CHANGELOG.md` under `### Changed`, add: - -```markdown -- `brev disable-ssh` now performs best-effort key cleanup at the caller's privilege level, reports incomplete local or remote cleanup as an error, and recommends an explicit `sudo brev disable-ssh` retry instead of automatically elevating. -``` - -Do not claim that root guarantees success; immutable files, read-only filesystems, malformed account data, and races still surface as nonzero errors. - -- [ ] **Step 3: Verify documentation terminology and formatting** - -Run: - -```bash -rg -n 'privileged root sweep|__brev-disable-ssh-cleanup|automatically elevat' docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md -git diff --check -``` - -Expected: the first command prints no matches and exits 1. `git diff --check` exits 0. - -- [ ] **Step 4: Run focused behavior and race verification** - -Run: - -```bash -go test ./pkg/cmd/disablessh -count=1 -go test -race ./pkg/cmd/disablessh -count=1 -go test ./pkg/integration -run '^Test_DisableSSHCleanupHelperIsNotAnEntrypoint$' -count=1 -go test ./pkg/cmd ./pkg/cmd/register ./pkg/cmd/enablessh ./pkg/cmd/deregister ./pkg/sudo -count=1 -``` - -Expected: all commands PASS. The race run preserves the descriptor-safe account-sweep coverage while exercising the new orchestration. - -- [ ] **Step 5: Verify root and platform builds plus scoped lint** - -Run on the macOS development host: - -```bash -go build . -go test -c -o /tmp/brev-disablessh-darwin.test ./pkg/cmd/disablessh -golangci-lint run . ./pkg/cmd/... ./pkg/sudo/... -``` - -Expected: all commands exit 0 and lint reports `0 issues`. - -When a Linux amd64 runner or cached Go 1.25 container is available, also run: - -```bash -docker run --rm --platform linux/amd64 -v "$PWD":/src -w /src golang:1.25 sh -lc 'go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh && go build -o /tmp/brev-cli-linux . && go test -race ./pkg/cmd/disablessh -run "^Test(SystemAuthorizedKeysCleaner|ReplaceAuthorizedKeys)" -count=1' -``` - -Expected: Linux test binary and CLI build succeed; the focused secure-rewrite race suite passes. - -- [ ] **Step 6: Attempt the repository-wide suite and classify only known baselines** - -Run: - -```bash -go test ./... -``` - -Expected: attempt the full suite. If the known untouched macOS baselines recur, record them separately: - -- `e2etest/setup`: hard-coded `/home/ubuntu/brev-cli`. -- `pkg/ssh`: unavailable JetBrains Gateway path followed by the existing nil panic. -- `pkg/store`: Windows/WSL expectations on Darwin. - -Any new failure in `main`, `pkg/integration`, `pkg/cmd/disablessh`, `pkg/cmd`, `pkg/cmd/register`, `pkg/cmd/enablessh`, `pkg/cmd/deregister`, or `pkg/sudo` blocks completion. - -- [ ] **Step 7: Review the final diff and commit docs** - -Run: - -```bash -git diff --check -git status --short -git diff --stat -git diff -``` - -Confirm the final diff contains no `main.go` feature dispatch, helper token, sudo gate, automatic elevation, node/port removal, NetBird uninstall, registration deletion, or unrelated edits. - -Then commit: - -```bash -git add docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md CHANGELOG.md -git commit -m "docs: explain disable-ssh sudo retry" -``` diff --git a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md index 52050d2b..1c902c83 100644 --- a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md +++ b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md @@ -10,11 +10,11 @@ explicit command boundaries: | Join the organization's Brev network | `brev join` | `brev register` | | Leave the organization's Brev network | `brev leave` | `brev deregister` | | Enable Brev-managed SSH for the current Brev/Linux user | `brev enable-ssh` | None | -| Disable all Brev-managed SSH access on the node | `brev disable-ssh` | None | +| Revoke all backend-tracked Brev SSH grants on the node | `brev disable-ssh` | None | `join` and `leave` own only durable Brev/NetBird membership. `enable-ssh` and -`disable-ssh` own Brev-managed SSH authentication. `grant-ssh` and `revoke-ssh` -remain the commands for individual collaborator grants. +`disable-ssh` own Brev-managed SSH authorization records. `grant-ssh` and +`revoke-ssh` remain the commands for individual collaborator grants. `register` and `deregister` remain deprecated Cobra aliases with no scheduled removal release. Their handlers and behavior are the same as their canonical @@ -33,10 +33,9 @@ commands, including the new separation from SSH. `revoke-ssh`. - Preserve compatible automation through deprecated `register` and `deregister` aliases, with actionable migration output. -- Make partial teardown failures visible and safely retryable. -- Let `disable-ssh` make best-effort progress without automatic elevation while - clearly directing non-root users to retry with `sudo` when public-key cleanup - is incomplete. +- Make partial backend revocation failures visible and safely retryable. +- Let `disable-ssh` make best-effort progress across every tracked grant while + revoking the invoking Brev user's own access last. ## Non-goals @@ -53,15 +52,12 @@ commands, including the new separation from SSH. - Tracking whether Brev installed NetBird. `leave` preserves today's NetBird uninstall behavior; protecting a pre-existing user-managed NetBird installation is a separate follow-up. -- Forcibly terminating already-established SSH sessions. Key removal prevents - future authentication but does not kill active sessions. -- Automatically elevating or re-executing the Brev binary for - `disable-ssh`. Users explicitly choose whether to run the command with - `sudo`. -- Promising that root can rewrite every `authorized_keys` file. Immutable files, - read-only filesystems, malformed account data, and concurrent modification can - still make cleanup fail; completion is represented by the command's exit - status. +- Editing host `authorized_keys` files from `disable-ssh`, including removing + Brev-tagged, orphaned, or otherwise static local keys. +- Forcibly terminating already-established SSH sessions. Revoking backend + authorization records does not kill active sessions. +- Adding a sudo gate, privileged helper, or same-binary re-execution path for + `disable-ssh`. ## Naming Rationale @@ -96,7 +92,8 @@ brev enable-ssh brev grant-ssh ``` -A complete retirement is intentionally two explicit operations: +Explicit tracked-grant revocation and membership retirement are intentionally +two operations: ```text brev disable-ssh @@ -104,8 +101,8 @@ brev leave ``` Running `leave` without `disable-ssh` is allowed. Brev-routed SSH stops because -the node leaves the network, but Brev-added keys can remain in local -`authorized_keys` files and may still work through another network path. +the node leaves the network. Neither operation removes local keys from +`authorized_keys`; any such keys may still work through another network path. ### Join and Register Alias @@ -213,63 +210,39 @@ Args: cobra.NoArgs, ``` It accepts `--approve` to skip confirmation. It operates only on the locally -registered node and means "disable every Brev-managed SSH credential on this -node." It does not mean "stop sshd." +registered node and means "revoke every backend-tracked Brev SSH grant on this +node." It does not mean "stop sshd" or "clean authorized_keys." The flow is: -1. Verify Linux compatibility. -2. Load local registration; if absent, direct the user to `brev join`. -3. When the effective UID is not root, write this warning to stderr: - - ```text - Warning: not running as root; public key cleanup may be incomplete. Re-run - "sudo brev disable-ssh" to allow cleanup across all local accounts. - ``` - -4. Show a node-wide confirmation using the locally registered device identity. - State that active sessions are not forcibly terminated. Remote grant counts - are not required before confirmation because authentication and backend - access must not block the local cleanup phase. `--approve` skips this prompt - but does not suppress either safety warning. - -5. Enumerate accounts reported by the local OS account database at the current - process privilege level and inspect only each account's - `.ssh/authorized_keys`. Remove only lines carrying Brev's current - `#brev-portID:...` marker or legacy `# brev-cli` marker. Attempt every - account, retain partial counts, and aggregate contextual errors rather than - stopping at the first unreadable or unwritable account. -6. Retain any local cleanup error and continue. Authenticate, fetch the current - registered backend node, and snapshot every remaining `SSHAccess` tuple. An - authentication or lookup failure is recorded as an incomplete remote-record - cleanup rather than hiding local progress. -7. When active records exist, ensure the existing Brev tunnel is connected so - remote revocation can complete. Reconnect existing membership automatically, - but never join. If no records exist, skip the tunnel and revocation work. -8. Call `RevokeNodeSSHAccess` sequentially for every tuple while the node and - its referenced ports still exist. Sequential execution avoids concurrent - rewrites of one Linux account's `authorized_keys`. Attempt all entries and - aggregate contextual failures. -9. After all independent work has been attempted, return a joined nonzero error - for either incomplete obligation. Local failures are reported as `failed to - clean up public keys`; authentication, lookup, tunnel, or revocation failures - are reported as `failed to remove remote SSH access records`. -10. Report overall success only after both local tagged-key cleanup and remote - record revocation succeed. - -No-access and no-key states are successful, making the command safely -repeatable. A retry does not rewrite files without Brev markers, fetches a fresh -backend access snapshot, skips already-removed records, and attempts only work -that remains. Membership and registration remain intact after every outcome so -either side can be retried. In particular, a non-root partial cleanup can be -retried with `sudo brev disable-ssh`; local cleanup runs before Brev -authentication so root's separate home or login state cannot prevent the key -sweep from being attempted. +1. Load local registration; if absent, direct the user to `brev join`. +2. Authenticate the invoking Brev user, fetch the registered backend node, and + take a fresh snapshot of every remaining `SSHAccess` tuple. +3. If the snapshot is empty, report that there are no grants to revoke and + return successfully without prompting. +4. Show a node-wide confirmation with the active grant count. State that active + sessions are not forcibly terminated. `--approve` skips this prompt but not + the active-session warning. +5. Stable-partition the snapshot so grants for collaborators remain first and + every grant belonging to the invoking Brev user is last. Preserve snapshot + order within each group. +6. Call `RevokeNodeSSHAccess` sequentially for every tuple. Continue after + individual failures, including through the invoking user's final record, and + aggregate contextual errors with user, Linux account, and port details. +7. Return nonzero if authentication, lookup, or any revocation is incomplete. + Report overall success only after every record in the fresh snapshot has + been revoked. + +A no-access state is successful, making the command safely repeatable. Each +retry fetches a fresh backend access snapshot, skips records already removed, +and attempts only work that remains. Membership and registration remain intact +after every outcome so revocation can be retried. `disable-ssh` does not remove the backend node, stop or uninstall NetBird, delete -registration, stop sshd, or close ports. Ports remain because the current API -cannot distinguish ports created for SSH from pre-existing ports selected by -the SSH flow. +registration, stop sshd, close ports, terminate active sessions, or inspect or +modify `authorized_keys`. Ports remain because the current API cannot +distinguish ports created for SSH from pre-existing ports selected by the SSH +flow. ### Leave and Deregister Alias @@ -286,8 +259,8 @@ stderr: ```text Warning: "brev deregister" is deprecated; use "brev leave" instead. -This command no longer removes SSH keys; run "brev disable-ssh" before leaving -if you want to remove Brev-managed SSH access. +This command does not revoke SSH access grants; run "brev disable-ssh" before +leaving if you want to revoke them. ``` The leave flow owns only membership teardown: @@ -298,9 +271,10 @@ The leave flow owns only membership teardown: error stops before mutation. 3. Always warn that removing the Brev tunnel may interrupt a command running through Brev SSH. Recommend running locally or through out-of-band access. -4. If SSH access records remain, explain that Brev-routed SSH will stop but host - keys will not be removed. Tell the user to cancel and run - `brev disable-ssh` first if key removal is desired. +4. If SSH access records remain, explain that Brev-routed SSH will stop and that + `leave` will not run the per-grant revocation flow. Tell the user to cancel + and run `brev disable-ssh` first if explicit best-effort revocation is + desired. 5. Confirm unless `--approve` was supplied. Warnings still print with `--approve`. 6. Obtain sudo authorization before network removal so local teardown will not @@ -333,23 +307,18 @@ return nonzero rather than producing a false successful completion. - `pkg/cmd/deregister` retains its internal package name but owns only leave orchestration. Its direct authorized-key removal dependency is removed. - `pkg/cmd/disablessh` is a focused new package with injected dependencies for - registration, node lookup, tunnel connectivity, confirmation, effective-UID - detection, grant revocation, and local account key cleanup. -- A narrow local key-cleanup abstraction enumerates account homes and removes - only Brev-tagged lines from each account's `.ssh/authorized_keys`, without - recursing through home directories. Rewrites preserve unrelated lines, - ownership, and file mode. Tests use a fake rather than touching real home - directories. -- `disable-ssh` invokes that cleaner directly at the current effective UID. It - has no sudo gate, hidden helper argument, same-binary privileged re-execution, - or special dispatch in `main.go`. The shared `pkg/sudo` behavior remains for - commands that still require it. + registration, current-user lookup, node lookup, confirmation, and grant + revocation. +- `disable-ssh` has no local key-cleanup abstraction, sudo gate, hidden helper + argument, same-binary privileged re-execution, or special dispatch in + `main.go`. The shared `pkg/sudo` behavior remains for commands that still + require it. - Tunnel management gains a strict connected operation suitable for SSH preconditions. It can start the service and run `netbird up` for existing membership, but returns an error unless connectivity is positively confirmed. -- `enable-ssh` always uses that strict tunnel operation. `disable-ssh` uses the - same operation when active grants require remote revocation, while a - no-grant run can proceed directly to orphaned local-key cleanup. +- `enable-ssh` always uses that strict tunnel operation. `disable-ssh`, like + `revoke-ssh`, calls the public revocation API without managing the local + tunnel. - User-facing guidance throughout the CLI changes from `brev register` to `brev join`. Internal and backend registration terminology remains where it describes persisted state or `AddNode`. @@ -364,23 +333,22 @@ return nonzero rather than producing a false successful completion. - All forms of the old SSH flag, `register --ssh-port`, `register -p`, `join --ssh-port`, and `join -p`, fail before side effects with migration guidance. -- `deregister` no longer removes the invoking user's Brev-tagged keys. Its - warning tells callers to run `disable-ssh` first when they want credential - cleanup. +- `deregister` retains the same membership-only behavior as `leave`. Its warning + tells callers to run `disable-ssh` first when they want explicit per-grant + backend revocation. - Removing either alias requires a future explicit compatibility decision. ## Error Handling and Recovery - Membership validation and strict tunnel connectivity precede every `enable-ssh` mutation. -- `disable-ssh` attempts local key cleanup before authentication or network - work, then attempts remote cleanup even when local cleanup is incomplete. -- `disable-ssh` attempts every reachable backend revocation, reports each failed - tuple with user, Linux account, and port context, and joins local and remote +- `disable-ssh` attempts every backend revocation in a fresh snapshot, reports + each failed tuple with user, Linux account, and port context, and joins the failures into one final error. -- A local success with a remote failure fails closed on the host but may leave - stale backend records. A remote success with a local failure leaves tagged - keys on one or more accounts. Both cases return nonzero and remain retryable. +- Collaborator records are attempted first and the invoking Brev user's own + records are attempted last, even after earlier failures. +- A partial revocation returns nonzero. A retry fetches a fresh snapshot and + attempts only records that remain. - `leave` deletes registration last and treats backend not-found as an idempotent retry condition. - Neither teardown command prints success after an incomplete operation. @@ -430,39 +398,26 @@ Tests verify: Tests verify: - Confirmation describes node-wide scope and can be bypassed with `--approve`. -- A non-root invocation warns that cleanup may be incomplete and recommends - `sudo brev disable-ssh`; a root invocation does not print that warning. -- `--approve` skips confirmation without suppressing the non-root or active- - session warnings. -- No disable flow invokes a sudo gate, subprocess runner, hidden helper mode, or - other automatic elevation path. +- `--approve` skips confirmation without suppressing the active-session warning. +- No disable flow invokes a key-cleanup dependency, sudo gate, subprocess + runner, hidden helper mode, or other automatic elevation path. - Every active access tuple is revoked exactly once. -- Active grants require a connected tunnel; a disconnected existing tunnel is - reconnected before revocation. -- Local cleanup occurs before Brev authentication, node lookup, tunnel access, - or revocation. +- Collaborator tuples preserve their snapshot order and are attempted before + every tuple owned by the invoking Brev user. - All tuples are attempted even when one fails, and errors are aggregated. -- Local cleanup errors do not block authentication or remote record cleanup; - remote errors do not erase or misreport local progress. -- Simultaneous local and remote failures are joined, retain both underlying - causes, include both `failed to clean up public keys` and `failed to remove - remote SSH access records`, and do not print overall success. Each single-side - failure includes only its applicable classification and underlying cause. -- Current and legacy Brev markers are removed across accessible account homes - while unrelated keys, ownership, and file modes remain intact. -- No-access and no-key runs succeed. -- A partial non-root run followed by a root retry does not rewrite already-clean - files or re-revoke records absent from the fresh backend snapshot. -- Authentication or backend lookup failure still attempts local cleanup and - returns an incomplete remote-record error. +- Aggregated failures retain their underlying causes, include `failed to revoke + one or more SSH access grants`, and do not print overall success. +- A no-access run succeeds without prompting or making revocation calls. +- A retry does not re-revoke records absent from the fresh backend snapshot. +- No `authorized_keys` file is inspected or modified. - No node removal, NetBird teardown, registration deletion, sshd operation, or - port close occurs. + port close occurs, and active sessions are not forcibly terminated. ### Leave Tests verify: -- Remaining grants produce the SSH-key warning but do not block leave. +- Remaining grants produce the tracked-access warning but do not block leave. - `--approve` skips confirmation but not warnings. - No SSH revoke or authorized-key dependency is called. - Ordering is backend removal, NetBird uninstall, then registration deletion. @@ -487,10 +442,11 @@ separately and will not be attributed to this change. - CLI help and examples use `join` and `leave` as the primary verbs. - Onboarding documents show `enable-ssh` as an explicit post-join choice. -- Offboarding documents show `disable-ssh` followed by `leave` for complete - credential and membership removal. -- `disable-ssh` documentation explains best-effort non-root cleanup, its - nonzero partial-failure result, and the `sudo brev disable-ssh` retry. +- Offboarding documents show `disable-ssh` followed by `leave` for explicit + tracked-grant revocation followed by membership removal. +- `disable-ssh` documentation explains best-effort backend revocation, its + nonzero partial-failure result, current-user-last ordering, and that local + `authorized_keys` files are outside its scope. - Documentation states that `leave` alone makes the node unreachable over the Brev network but does not remove host keys. - Release notes call out both deprecated aliases and the SSH behavior change. diff --git a/main.go b/main.go index b87c2f67..66b4d97e 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,15 @@ package main import ( - "context" - "fmt" "os" "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/cmd" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" - "github.com/brevdev/brev-cli/pkg/cmd/disablessh" "github.com/brevdev/brev-cli/pkg/errors" ) func main() { - handled, err := disablessh.RunLocalKeyCleanupHelper(context.Background(), os.Args[1:], os.Stdout) - if handled { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return - } - done := errors.GetDefaultErrorReporter().Setup() defer done() defer analytics.Close() diff --git a/pkg/cmd/deregister/deregister.go b/pkg/cmd/deregister/deregister.go index 0a58ce6c..b12af73d 100644 --- a/pkg/cmd/deregister/deregister.go +++ b/pkg/cmd/deregister/deregister.go @@ -60,8 +60,8 @@ func defaultLeaveDeps() leaveDeps { const leaveLong = `Leave the Brev network This removes the backend node, uninstalls the Brev tunnel, and deletes local -registration data. It does not revoke SSH grants or remove authorized_keys -entries; run "brev disable-ssh" first when those credentials should be removed.` +registration data. It does not revoke SSH access grants; run "brev disable-ssh" +first when those grants should be revoked.` // NewCmdLeave creates the canonical network-membership teardown command. func NewCmdLeave(t *terminal.Terminal, store LeaveStore) *cobra.Command { @@ -90,7 +90,7 @@ func newCmdLeave(t *terminal.Terminal, store LeaveStore, deps leaveDeps) *cobra. RunE: func(cmd *cobra.Command, _ []string) error { if cmd.CalledAs() == "deregister" { _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev deregister" is deprecated; use "brev leave" instead.`) - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer removes SSH keys; run "brev disable-ssh" before leaving if you want to remove Brev-managed SSH access.`) + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), `This command does not revoke SSH access grants; run "brev disable-ssh" before leaving if you want to revoke them.`) } return runLeave(cmd.Context(), t, cmd.ErrOrStderr(), store, deps, approveFlag) }, @@ -129,12 +129,12 @@ func runLeave( } _, _ = fmt.Fprintln(warnings, "Leaving removes the Brev tunnel and may interrupt commands using Brev SSH. Run this locally or through out-of-band access.") if missing { - _, _ = fmt.Fprintln(warnings, "Warning: the backend node is already absent; tagged host keys may remain on this machine.") + _, _ = fmt.Fprintln(warnings, "Warning: the backend node is already absent; skipping SSH grant inspection.") } else { grantCount, accountCount := remainingSSHAccessCounts(node.GetSshAccess()) if grantCount > 0 { _, _ = fmt.Fprintf(warnings, "Warning: %d SSH grants across %d Linux accounts remain on this node.\n", grantCount, accountCount) - _, _ = fmt.Fprintln(warnings, `Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed.`) + _, _ = fmt.Fprintln(warnings, `Leaving stops Brev-routed SSH but does not revoke these grants. Cancel and run "brev disable-ssh" first if you want them revoked.`) } } diff --git a/pkg/cmd/deregister/deregister_test.go b/pkg/cmd/deregister/deregister_test.go index 45d1a9e6..757d2bb8 100644 --- a/pkg/cmd/deregister/deregister_test.go +++ b/pkg/cmd/deregister/deregister_test.go @@ -248,7 +248,7 @@ func TestNewCmdLeave_DeregisterAliasWarnsOnExecution(t *testing.T) { }) require.NoError(t, err) require.True(t, strings.HasPrefix(stderr.String(), "Warning: \"brev deregister\" is deprecated; use \"brev leave\" instead.\n"+ - "This command no longer removes SSH keys; run \"brev disable-ssh\" before leaving if you want to remove Brev-managed SSH access.\n")) + "This command does not revoke SSH access grants; run \"brev disable-ssh\" before leaving if you want to revoke them.\n")) } func TestNewCmdLeave_HelpDoesNotWarn(t *testing.T) { @@ -312,7 +312,7 @@ func TestRunLeave_RemainingGrantsWarnButDoNotBlock(t *testing.T) { _, stderr, err := h.run(t, false) require.NoError(t, err) require.Contains(t, stderr, "3 SSH grants across 2 Linux accounts") - require.Contains(t, stderr, `Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed.`) + require.Contains(t, stderr, `Leaving stops Brev-routed SSH but does not revoke these grants. Cancel and run "brev disable-ssh" first if you want them revoked.`) require.Len(t, h.client.removeRequests, 1) } @@ -416,7 +416,7 @@ func TestRunLeave_CompleteNodeListWithoutRegisteredIDAllowsAuthoritativeRemoveRe _, stderr, err := h.run(t, true) require.NoError(t, err) require.Contains(t, stderr, "backend node is already absent") - require.Contains(t, stderr, "tagged host keys may remain") + require.Contains(t, stderr, "skipping SSH grant inspection") require.Len(t, h.client.removeRequests, 1) } diff --git a/pkg/cmd/disablessh/disablessh.go b/pkg/cmd/disablessh/disablessh.go index 4a814894..7fe03ed1 100644 --- a/pkg/cmd/disablessh/disablessh.go +++ b/pkg/cmd/disablessh/disablessh.go @@ -15,7 +15,6 @@ import ( "github.com/brevdev/brev-cli/pkg/entity" breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/externalnode" - "github.com/brevdev/brev-cli/pkg/sudo" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/spf13/cobra" @@ -28,24 +27,16 @@ type DisableSSHStore interface { } type disableSSHDeps struct { - platform externalnode.PlatformChecker confirmer terminal.Confirmer - gater sudo.Gater - tunnel register.NetBirdConnector nodeClients externalnode.NodeClientFactory registrationStore register.RegistrationStore - keyCleaner localKeyCleaner } func defaultDisableSSHDeps() disableSSHDeps { return disableSSHDeps{ - platform: register.LinuxPlatform{}, confirmer: register.TerminalPrompter{}, - gater: sudo.Default, - tunnel: register.Netbird{}, nodeClients: register.DefaultNodeClientFactory{}, registrationStore: register.NewFileRegistrationStore(), - keyCleaner: newPrivilegedLocalKeyCleaner(), } } @@ -60,8 +51,8 @@ func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableS Annotations: map[string]string{"configuration": ""}, Use: "disable-ssh", DisableFlagsInUseLine: true, - Short: "Disable all Brev-managed SSH access on this node", - Long: "Disable every Brev-managed SSH credential on this joined node without changing Brev network membership or the SSH daemon.", + Short: "Revoke all Brev SSH access grants on this node", + Long: "Revoke every Brev SSH access grant on this joined node without changing Brev network membership or the SSH daemon.", Example: " brev disable-ssh\n brev disable-ssh --approve", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -79,11 +70,7 @@ func runDisableSSH( store DisableSSHStore, deps disableSSHDeps, skipConfirm bool, -) error { //nolint:funlen // Ordered teardown state machine is intentionally explicit. - if !deps.platform.IsCompatible() { - return fmt.Errorf("brev disable-ssh is only supported on Linux") - } - +) error { //nolint:funlen // Keep the node-wide confirmation and revocation flow linear. exists, err := deps.registrationStore.Exists() if err != nil { return fmt.Errorf("check joined-device registration: %w", err) @@ -96,30 +83,37 @@ func runDisableSSH( if err != nil { return fmt.Errorf("read joined-device registration: %w", err) } - if _, err := store.GetCurrentUser(); err != nil { + currentUser, err := store.GetCurrentUser() + if err != nil { return breverrors.WrapAndTrace(err) } + if currentUser == nil || currentUser.ID == "" { + return fmt.Errorf("get current Brev user: missing user ID") + } node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, store, reg) if err != nil { return fmt.Errorf("disable SSH failed: %w", err) } - accesses := snapshotSSHAccess(node.GetSshAccess()) - linuxAccounts := distinctLinuxAccountCount(accesses) + accesses := snapshotSSHAccessForRevocation(node.GetSshAccess(), currentUser.ID) t.Vprint("") - t.Vprint(t.White("══════════════════════════════════════════════════")) - t.Vprint(t.White(" Disabling Brev-managed SSH access")) - t.Vprint(t.White("══════════════════════════════════════════════════")) + t.Vprint(t.White("════════════════════════════════════════════")) + t.Vprint(t.White(" Disabling Brev SSH access")) + t.Vprint(t.White("════════════════════════════════════════════")) t.Vprint("") - t.Vprintf(" Node: %s (%s)\n", node.GetName(), node.GetExternalNodeId()) - t.Vprintf(" SSH grants: %d\n", len(accesses)) - t.Vprintf(" Linux accounts: %d\n", linuxAccounts) + t.Vprintf(" Node: %s (%s)\n", node.GetName(), node.GetExternalNodeId()) + t.Vprintf(" SSH grants: %d\n", len(accesses)) t.Vprint("") + if len(accesses) == 0 { + t.Vprint(t.Green("No SSH access grants to revoke.")) + return nil + } + if warnings == nil { warnings = io.Discard } - _, _ = fmt.Fprintln(warnings, "Warning: this is a node-wide operation that removes all Brev-managed SSH credentials on this node.") + _, _ = fmt.Fprintln(warnings, "Warning: this is a node-wide operation that revokes all Brev SSH access grants on this node.") _, _ = fmt.Fprintln(warnings, "Warning: active SSH sessions are not forcibly terminated.") if !skipConfirm && !deps.confirmer.ConfirmYesNo("Disable all Brev-managed SSH access on this node?") { @@ -127,25 +121,12 @@ func runDisableSSH( return nil } - if err := deps.gater.Gate(t, deps.confirmer, "Node-wide Brev SSH cleanup", true); err != nil { - return fmt.Errorf("sudo issue: %w", err) - } - - if len(accesses) > 0 { - if err := deps.tunnel.EnsureConnected(ctx); err != nil { - return fmt.Errorf("disable SSH requires a connected Brev tunnel: %w", err) - } - client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) - if err := revokeSSHAccesses(ctx, client, reg.ExternalNodeID, accesses); err != nil { - return err - } + client := deps.nodeClients.NewNodeClient(store, config.GlobalConfig.GetBrevPublicAPIURL()) + if err := revokeSSHAccesses(ctx, client, reg.ExternalNodeID, accesses); err != nil { + return err } - result, err := deps.keyCleaner.RemoveBrevKeys(ctx) - if err != nil { - return fmt.Errorf("disable SSH local key cleanup incomplete: %w", err) - } - t.Vprintf("%s SSH access disabled: %d keys removed; %d accounts changed.\n", t.Green(" ✓"), result.KeysRemoved, result.AccountsChanged) + t.Vprintf("%s SSH access disabled. Grants revoked: %d.\n", t.Green(" ✓"), len(accesses)) return nil } @@ -174,25 +155,23 @@ func revokeSSHAccesses( } } if err := breverrors.Join(revokeErrs...); err != nil { - return fmt.Errorf("disable SSH backend cleanup incomplete: %w", err) + return fmt.Errorf("failed to revoke one or more SSH access grants: %w", err) } return nil } -func snapshotSSHAccess(accesses []*nodev1.SSHAccess) []*nodev1.SSHAccess { +func snapshotSSHAccessForRevocation(accesses []*nodev1.SSHAccess, currentUserID string) []*nodev1.SSHAccess { snapshot := make([]*nodev1.SSHAccess, 0, len(accesses)) + currentUserAccesses := make([]*nodev1.SSHAccess, 0, len(accesses)) for _, access := range accesses { - if access != nil { - snapshot = append(snapshot, access) + if access == nil { + continue } + if access.GetUserId() == currentUserID { + currentUserAccesses = append(currentUserAccesses, access) + continue + } + snapshot = append(snapshot, access) } - return snapshot -} - -func distinctLinuxAccountCount(accesses []*nodev1.SSHAccess) int { - accounts := make(map[string]struct{}, len(accesses)) - for _, access := range accesses { - accounts[access.GetLinuxUser()] = struct{}{} - } - return len(accounts) + return append(snapshot, currentUserAccesses...) } diff --git a/pkg/cmd/disablessh/disablessh_test.go b/pkg/cmd/disablessh/disablessh_test.go index 096e8aec..280c17d7 100644 --- a/pkg/cmd/disablessh/disablessh_test.go +++ b/pkg/cmd/disablessh/disablessh_test.go @@ -7,10 +7,7 @@ import ( "fmt" "io" "os" - "strings" - "sync" "testing" - "time" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" nodev1 "buf.build/gen/go/brevdev/devplane/protocolbuffers/go/devplaneapi/v1" @@ -23,39 +20,22 @@ import ( "github.com/brevdev/brev-cli/pkg/terminal" ) -type disableSSHTestPlatform struct { - compatible bool - events *[]string -} - -func (p *disableSSHTestPlatform) IsCompatible() bool { - recordDisableSSHEvent(p.events, "platform") - return p.compatible -} - type disableSSHTestStore struct { - events *[]string - currentUserCalls int - accessTokenCalls int + currentUser *entity.User currentUserErr error + currentUserCalls int } func (s *disableSSHTestStore) GetCurrentUser() (*entity.User, error) { s.currentUserCalls++ - recordDisableSSHEvent(s.events, "auth") - if s.currentUserErr != nil { - return nil, s.currentUserErr - } - return &entity.User{ID: "user_current"}, nil + return s.currentUser, s.currentUserErr } -func (s *disableSSHTestStore) GetAccessToken() (string, error) { - s.accessTokenCalls++ +func (*disableSSHTestStore) GetAccessToken() (string, error) { return "token", nil } type disableSSHTestRegistrationStore struct { - events *[]string exists bool existsErr error loadErr error @@ -65,104 +45,41 @@ type disableSSHTestRegistrationStore struct { } func (s *disableSSHTestRegistrationStore) Exists() (bool, error) { - recordDisableSSHEvent(s.events, "registration-exists") return s.exists, s.existsErr } func (s *disableSSHTestRegistrationStore) Load() (*register.DeviceRegistration, error) { - recordDisableSSHEvent(s.events, "registration-load") - if s.loadErr != nil { - return nil, s.loadErr - } - return s.reg, nil + return s.reg, s.loadErr } func (s *disableSSHTestRegistrationStore) Save(*register.DeviceRegistration) error { s.saveCalls++ - recordDisableSSHEvent(s.events, "registration-save") return nil } func (s *disableSSHTestRegistrationStore) Delete() error { s.deleteCalls++ - recordDisableSSHEvent(s.events, "registration-delete") return nil } type disableSSHTestConfirmer struct { - events *[]string answer bool calls int - labels []string } -func (c *disableSSHTestConfirmer) ConfirmYesNo(label string) bool { +func (c *disableSSHTestConfirmer) ConfirmYesNo(string) bool { c.calls++ - c.labels = append(c.labels, label) - recordDisableSSHEvent(c.events, "confirm") return c.answer } -type disableSSHTestGater struct { - events *[]string - calls int - reasons []string - err error -} - -func (g *disableSSHTestGater) Gate(_ *terminal.Terminal, _ terminal.Confirmer, reason string, _ bool) error { - g.calls++ - g.reasons = append(g.reasons, reason) - recordDisableSSHEvent(g.events, "sudo") - return g.err -} - -type disableSSHTestTunnel struct { - events *[]string - ensureCalls int - uninstallCalls int - err error -} - -func (t *disableSSHTestTunnel) EnsureConnected(context.Context) error { - t.ensureCalls++ - recordDisableSSHEvent(t.events, "tunnel") - return t.err -} - -// Uninstall is deliberately outside register.NetBirdConnector. It makes an -// accidental concrete-type assertion or broadened dependency observable. -func (t *disableSSHTestTunnel) Uninstall() error { - t.uninstallCalls++ - recordDisableSSHEvent(t.events, "netbird-uninstall") - return nil -} - -type disableSSHTestKeyCleaner struct { - events *[]string - result KeyCleanupResult - err error - calls int -} - -func (c *disableSSHTestKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) { - c.calls++ - recordDisableSSHEvent(c.events, "cleanup") - return c.result, c.err -} - type disableSSHRecordingClient struct { nodev1connect.ExternalNodeServiceClient - events *[]string - node *nodev1.ExternalNode - getErr error - - mu sync.Mutex - revokeRequests []*nodev1.RevokeNodeSSHAccessRequest - revokeErrors map[int]error - activeRevokes int - maxActiveRevokes int + node *nodev1.ExternalNode + getErr error + getNodeCalls int + revokeErrors map[int]error + revokeRequests []*nodev1.RevokeNodeSSHAccessRequest addNodeCalls int removeNodeCalls int @@ -170,7 +87,7 @@ type disableSSHRecordingClient struct { } func (c *disableSSHRecordingClient) GetNode(_ context.Context, req *connect.Request[nodev1.GetNodeRequest]) (*connect.Response[nodev1.GetNodeResponse], error) { - recordDisableSSHEvent(c.events, "get-node") + c.getNodeCalls++ if c.getErr != nil { return nil, c.getErr } @@ -181,23 +98,9 @@ func (c *disableSSHRecordingClient) GetNode(_ context.Context, req *connect.Requ } func (c *disableSSHRecordingClient) RevokeNodeSSHAccess(_ context.Context, req *connect.Request[nodev1.RevokeNodeSSHAccessRequest]) (*connect.Response[nodev1.RevokeNodeSSHAccessResponse], error) { - c.mu.Lock() callIndex := len(c.revokeRequests) c.revokeRequests = append(c.revokeRequests, cloneRevokeRequest(req.Msg)) - c.activeRevokes++ - if c.activeRevokes > c.maxActiveRevokes { - c.maxActiveRevokes = c.activeRevokes - } - c.mu.Unlock() - - recordDisableSSHEvent(c.events, "revoke:"+req.Msg.GetUserId()) - time.Sleep(time.Millisecond) - - c.mu.Lock() - c.activeRevokes-- - err := c.revokeErrors[callIndex] - c.mu.Unlock() - if err != nil { + if err := c.revokeErrors[callIndex]; err != nil { return nil, err } return connect.NewResponse(&nodev1.RevokeNodeSSHAccessResponse{}), nil @@ -205,19 +108,16 @@ func (c *disableSSHRecordingClient) RevokeNodeSSHAccess(_ context.Context, req * func (c *disableSSHRecordingClient) AddNode(context.Context, *connect.Request[nodev1.AddNodeRequest]) (*connect.Response[nodev1.AddNodeResponse], error) { c.addNodeCalls++ - recordDisableSSHEvent(c.events, "add-node") return connect.NewResponse(&nodev1.AddNodeResponse{}), nil } func (c *disableSSHRecordingClient) RemoveNode(context.Context, *connect.Request[nodev1.RemoveNodeRequest]) (*connect.Response[nodev1.RemoveNodeResponse], error) { c.removeNodeCalls++ - recordDisableSSHEvent(c.events, "remove-node") return connect.NewResponse(&nodev1.RemoveNodeResponse{}), nil } func (c *disableSSHRecordingClient) ClosePort(context.Context, *connect.Request[nodev1.ClosePortRequest]) (*connect.Response[nodev1.ClosePortResponse], error) { c.closePortCalls++ - recordDisableSSHEvent(c.events, "close-port") return connect.NewResponse(&nodev1.ClosePortResponse{}), nil } @@ -230,50 +130,39 @@ func (f disableSSHTestNodeClientFactory) NewNodeClient(externalnode.TokenProvide } type disableSSHTestHarness struct { - events []string store *disableSSHTestStore registrations *disableSSHTestRegistrationStore confirmer *disableSSHTestConfirmer - gater *disableSSHTestGater - tunnel *disableSSHTestTunnel - cleaner *disableSSHTestKeyCleaner client *disableSSHRecordingClient deps disableSSHDeps } func newDisableSSHTestHarness(accesses ...*nodev1.SSHAccess) *disableSSHTestHarness { - h := &disableSSHTestHarness{} - h.store = &disableSSHTestStore{events: &h.events} - h.registrations = &disableSSHTestRegistrationStore{ - events: &h.events, - exists: true, - reg: ®ister.DeviceRegistration{ - ExternalNodeID: "node_123", - DisplayName: "owned-node", - OrgID: "org_123", - OrgName: "owned-org", + h := &disableSSHTestHarness{ + store: &disableSSHTestStore{currentUser: &entity.User{ID: "user_current"}}, + registrations: &disableSSHTestRegistrationStore{ + exists: true, + reg: ®ister.DeviceRegistration{ + ExternalNodeID: "node_123", + DisplayName: "owned-node", + OrgID: "org_123", + OrgName: "owned-org", + }, + }, + confirmer: &disableSSHTestConfirmer{answer: true}, + client: &disableSSHRecordingClient{ + node: &nodev1.ExternalNode{ + ExternalNodeId: "node_123", + Name: "owned-node", + SshAccess: accesses, + }, + revokeErrors: make(map[int]error), }, - } - h.confirmer = &disableSSHTestConfirmer{events: &h.events, answer: true} - h.gater = &disableSSHTestGater{events: &h.events} - h.tunnel = &disableSSHTestTunnel{events: &h.events} - h.cleaner = &disableSSHTestKeyCleaner{ - events: &h.events, - result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}, - } - h.client = &disableSSHRecordingClient{ - events: &h.events, - node: &nodev1.ExternalNode{ExternalNodeId: "node_123", Name: "owned-node", SshAccess: accesses}, - revokeErrors: make(map[int]error), } h.deps = disableSSHDeps{ - platform: &disableSSHTestPlatform{compatible: true, events: &h.events}, confirmer: h.confirmer, - gater: h.gater, - tunnel: h.tunnel, nodeClients: disableSSHTestNodeClientFactory{client: h.client}, registrationStore: h.registrations, - keyCleaner: h.cleaner, } return h } @@ -290,7 +179,7 @@ func (h *disableSSHTestHarness) run(t *testing.T, skipConfirm bool) (stdout stri func TestNewCmdDisableSSH_CommandSurface(t *testing.T) { cmd := NewCmdDisableSSH(terminal.New(), &disableSSHTestStore{}) require.Equal(t, "disable-ssh", cmd.Use) - require.Equal(t, "Disable all Brev-managed SSH access on this node", cmd.Short) + require.Equal(t, "Revoke all Brev SSH access grants on this node", cmd.Short) require.NotNil(t, cmd.Args) require.Contains(t, cmd.Annotations, "configuration") require.Empty(t, cmd.Aliases) @@ -306,8 +195,7 @@ func TestNewCmdDisableSSH_RejectsArguments(t *testing.T) { err := cmd.Execute() require.Error(t, err) - require.Contains(t, err.Error(), "unknown command") - require.Empty(t, h.events) + require.Empty(t, h.client.revokeRequests) } func TestRunDisableSSH_MissingRegistrationDoesNotAuthenticateOrCallRPC(t *testing.T) { @@ -316,233 +204,139 @@ func TestRunDisableSSH_MissingRegistrationDoesNotAuthenticateOrCallRPC(t *testin _, _, err := h.run(t, false) require.EqualError(t, err, `This machine has not joined a Brev network; run "brev join" first.`) - require.Equal(t, []string{"platform", "registration-exists"}, h.events) require.Zero(t, h.store.currentUserCalls) + require.Zero(t, h.client.getNodeCalls) require.Empty(t, h.client.revokeRequests) - require.Zero(t, h.tunnel.ensureCalls) - require.Zero(t, h.gater.calls) - require.Zero(t, h.cleaner.calls) } -func TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - h.confirmer.answer = false +func TestRunDisableSSH_RequiresCurrentUserIDBeforeLoadingGrants(t *testing.T) { + authErr := errors.New("authentication failed") + tests := []struct { + name string + currentUser *entity.User + currentErr error + }{ + {name: "authentication failure", currentErr: authErr}, + {name: "missing user"}, + {name: "missing user ID", currentUser: &entity.User{}}, + } - _, _, err := h.run(t, false) - require.NoError(t, err) - require.Equal(t, []string{"platform", "registration-exists", "registration-load", "auth", "get-node", "confirm"}, h.events) - require.Zero(t, h.gater.calls) - require.Zero(t, h.tunnel.ensureCalls) - require.Empty(t, h.client.revokeRequests) - require.Zero(t, h.cleaner.calls) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_current", "ubuntu", "port_1")) + h.store.currentUser = tt.currentUser + h.store.currentUserErr = tt.currentErr + + _, _, err := h.run(t, true) + require.Error(t, err) + if tt.currentErr != nil { + require.ErrorIs(t, err, tt.currentErr) + } else { + require.Contains(t, err.Error(), "missing user ID") + } + require.Zero(t, h.client.getNodeCalls) + require.Zero(t, h.confirmer.calls) + require.Empty(t, h.client.revokeRequests) + }) + } } -func TestRunDisableSSH_ApproveSkipsConfirmationButPrintsSafetyWarning(t *testing.T) { +func TestRunDisableSSH_NoGrantsReportsAlreadyDisabledWithoutPrompting(t *testing.T) { h := newDisableSSHTestHarness() - _, stderr, err := h.run(t, true) + stdout, stderr, err := h.run(t, false) require.NoError(t, err) + require.Contains(t, stdout, "No SSH access grants to revoke.") + require.NotContains(t, stdout, "keys removed") + require.Empty(t, stderr) require.Zero(t, h.confirmer.calls) - require.Contains(t, stderr, "node-wide") - require.Contains(t, stderr, "active SSH sessions are not forcibly terminated") - require.Equal(t, 1, h.cleaner.calls) -} - -func TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts(t *testing.T) { - h := newDisableSSHTestHarness( - testSSHAccess("user_1", "ubuntu", "port_1"), - testSSHAccess("user_2", "ubuntu", "port_2"), - testSSHAccess("user_3", "alice", "port_3"), - ) - - stdout, _, err := h.run(t, true) - require.NoError(t, err) - for _, text := range []string{"owned-node", "node_123", "SSH grants: 3", "Linux accounts: 2"} { - require.Contains(t, stdout, text) - } + require.Empty(t, h.client.revokeRequests) } -func TestRunDisableSSH_IgnoresNilAccessEntries(t *testing.T) { - h := newDisableSSHTestHarness( - testSSHAccess("user_1", "ubuntu", "port_1"), - nil, - testSSHAccess("user_2", "alice", "port_2"), - ) +func TestRunDisableSSH_CancelDoesNotRevoke(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_collaborator", "ubuntu", "port_1")) + h.confirmer.answer = false - stdout, _, err := h.run(t, true) + stdout, stderr, err := h.run(t, false) require.NoError(t, err) - require.Contains(t, stdout, "SSH grants: 2") - require.Len(t, h.client.revokeRequests, 2) + require.Contains(t, stdout, "Disable SSH canceled.") + require.Contains(t, stderr, "node-wide operation") + require.Equal(t, 1, h.confirmer.calls) + require.Empty(t, h.client.revokeRequests) } -func TestRunDisableSSH_ConnectsBeforeFirstRevocation(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) +func TestRunDisableSSH_ApproveRevokesWithoutPrompting(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_collaborator", "ubuntu", "port_1")) - _, _, err := h.run(t, true) + stdout, stderr, err := h.run(t, true) require.NoError(t, err) - requireOrderedSubsequence(t, h.events, "sudo", "tunnel", "revoke:user_1", "cleanup") + require.Zero(t, h.confirmer.calls) + require.Contains(t, stderr, "active SSH sessions are not forcibly terminated") + require.Contains(t, stdout, "SSH access disabled. Grants revoked: 1.") + require.Len(t, h.client.revokeRequests, 1) } -func TestRunDisableSSH_RevokesEveryExactTupleSequentiallyOnce(t *testing.T) { - accesses := []*nodev1.SSHAccess{ - testSSHAccess("user_1", "ubuntu", "port_1"), - testSSHAccess("user_2", "ubuntu", "port_2"), - testSSHAccess("user_3", "alice", "port_3"), - } - h := newDisableSSHTestHarness(accesses...) +func TestRunDisableSSH_RevokesEveryExactTupleWithCurrentUsersAccessLast(t *testing.T) { + h := newDisableSSHTestHarness( + testSSHAccess("user_current", "ubuntu", "port_self_1"), + testSSHAccess("user_collaborator_1", "alice", "port_collaborator_1"), + nil, + testSSHAccess("user_current", "root", "port_self_2"), + testSSHAccess("user_collaborator_2", "carol", "port_collaborator_2"), + ) _, _, err := h.run(t, true) require.NoError(t, err) - require.Equal(t, 1, h.client.maxActiveRevokes) - require.Len(t, h.client.revokeRequests, len(accesses)) - for i, access := range accesses { - require.Equal(t, &nodev1.RevokeNodeSSHAccessRequest{ - ExternalNodeId: "node_123", - PortId: access.GetPortId(), - UserId: access.GetUserId(), - LinuxUser: access.GetLinuxUser(), - }, h.client.revokeRequests[i]) - } + require.Equal(t, []*nodev1.RevokeNodeSSHAccessRequest{ + {ExternalNodeId: "node_123", UserId: "user_collaborator_1", LinuxUser: "alice", PortId: "port_collaborator_1"}, + {ExternalNodeId: "node_123", UserId: "user_collaborator_2", LinuxUser: "carol", PortId: "port_collaborator_2"}, + {ExternalNodeId: "node_123", UserId: "user_current", LinuxUser: "ubuntu", PortId: "port_self_1"}, + {ExternalNodeId: "node_123", UserId: "user_current", LinuxUser: "root", PortId: "port_self_2"}, + }, h.client.revokeRequests) } -func TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors(t *testing.T) { +func TestRunDisableSSH_ContinuesAfterFailuresAndReturnsEveryCause(t *testing.T) { firstErr := errors.New("first revoke failed") - middleErr := errors.New("middle revoke failed") + secondErr := errors.New("second revoke failed") h := newDisableSSHTestHarness( testSSHAccess("user_1", "ubuntu", "port_1"), testSSHAccess("user_2", "alice", "port_2"), - testSSHAccess("user_3", "carol", "port_3"), + testSSHAccess("user_current", "root", "port_self"), ) h.client.revokeErrors[0] = firstErr - h.client.revokeErrors[1] = middleErr + h.client.revokeErrors[1] = secondErr _, _, err := h.run(t, true) - require.Error(t, err) require.ErrorIs(t, err, firstErr) - require.ErrorIs(t, err, middleErr) - require.Contains(t, err.Error(), "disable SSH backend cleanup incomplete") + require.ErrorIs(t, err, secondErr) + require.Contains(t, err.Error(), "failed to revoke one or more SSH access grants") for _, text := range []string{"user_1", "ubuntu", "port_1", "user_2", "alice", "port_2"} { require.Contains(t, err.Error(), text) } require.Len(t, h.client.revokeRequests, 3) - require.Equal(t, []string{"revoke:user_1", "revoke:user_2", "revoke:user_3"}, filterDisableSSHEvents(h.events, "revoke:")) -} - -func TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup(t *testing.T) { - h := newDisableSSHTestHarness( - testSSHAccess("user_1", "ubuntu", "port_1"), - testSSHAccess("user_2", "alice", "port_2"), - ) - h.client.revokeErrors[0] = errors.New("revocation failed") - - _, _, err := h.run(t, true) - require.Error(t, err) - require.Len(t, h.client.revokeRequests, 2) - require.Zero(t, h.cleaner.calls) -} - -func TestRunDisableSSH_NotFoundRevocationBlocksLocalCleanup(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - h.client.revokeErrors[0] = connect.NewError(connect.CodeNotFound, errors.New("port missing")) - - _, _, err := h.run(t, true) - require.Error(t, err) - require.Equal(t, connect.CodeNotFound, connect.CodeOf(err)) - require.Zero(t, h.cleaner.calls) -} - -func TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys(t *testing.T) { - h := newDisableSSHTestHarness() - - _, _, err := h.run(t, true) - require.NoError(t, err) - require.Zero(t, h.tunnel.ensureCalls) - require.Empty(t, h.client.revokeRequests) - require.Equal(t, 1, h.cleaner.calls) - requireOrderedSubsequence(t, h.events, "sudo", "cleanup") -} - -func TestRunDisableSSH_TunnelFailureStopsBeforeRevocationAndCleanup(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - tunnelErr := errors.New("tunnel unavailable") - h.tunnel.err = tunnelErr - - _, _, err := h.run(t, true) - require.ErrorIs(t, err, tunnelErr) - require.Contains(t, err.Error(), "connected Brev tunnel") - require.Empty(t, h.client.revokeRequests) - require.Zero(t, h.cleaner.calls) -} - -func TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - cleanupErr := errors.New("local cleanup failed") - h.cleaner.err = cleanupErr - - _, _, err := h.run(t, true) - require.ErrorIs(t, err, cleanupErr) - require.Contains(t, err.Error(), "disable SSH local key cleanup incomplete") - require.Equal(t, 1, h.cleaner.calls) - require.Zero(t, h.client.removeNodeCalls) - require.Zero(t, h.registrations.deleteCalls) - require.Zero(t, h.tunnel.uninstallCalls) } -func TestRunDisableSSH_DoesNotRemoveNodeClosePortUninstallNetBirdOrDeleteRegistration(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) +func TestRunDisableSSH_DoesNotChangeMembershipPortsOrRegistration(t *testing.T) { + h := newDisableSSHTestHarness(testSSHAccess("user_collaborator", "ubuntu", "port_1")) _, _, err := h.run(t, true) require.NoError(t, err) + require.Zero(t, h.client.addNodeCalls) require.Zero(t, h.client.removeNodeCalls) require.Zero(t, h.client.closePortCalls) - require.Zero(t, h.client.addNodeCalls) - require.Zero(t, h.tunnel.uninstallCalls) - require.Zero(t, h.registrations.deleteCalls) require.Zero(t, h.registrations.saveCalls) + require.Zero(t, h.registrations.deleteCalls) } -func TestRunDisableSSH_SuccessIncludesCleanupCounts(t *testing.T) { - h := newDisableSSHTestHarness() - - stdout, _, err := h.run(t, true) - require.NoError(t, err) - require.Contains(t, stdout, "3") - require.Contains(t, stdout, "keys removed") - require.Contains(t, stdout, "2") - require.Contains(t, stdout, "accounts changed") -} - -func TestRunDisableSSH_StateMachineOrdersPreflightConfirmationAndSudo(t *testing.T) { - h := newDisableSSHTestHarness(testSSHAccess("user_1", "ubuntu", "port_1")) - - _, _, err := h.run(t, false) - require.NoError(t, err) - require.Equal(t, []string{ - "platform", - "registration-exists", - "registration-load", - "auth", - "get-node", - "confirm", - "sudo", - "tunnel", - "revoke:user_1", - "cleanup", - }, h.events) - require.Equal(t, []string{"Node-wide Brev SSH cleanup"}, h.gater.reasons) -} - -func TestRunDisableSSH_BackendNodeFailureStopsBeforeConfirmationAndMutation(t *testing.T) { +func TestRunDisableSSH_BackendNodeFailureStopsBeforeConfirmation(t *testing.T) { h := newDisableSSHTestHarness() h.client.getErr = errors.New("backend unavailable") _, _, err := h.run(t, false) require.Error(t, err) - require.Equal(t, []string{"platform", "registration-exists", "registration-load", "auth", "get-node"}, h.events) + require.Contains(t, err.Error(), "disable SSH failed") require.Zero(t, h.confirmer.calls) - require.Zero(t, h.gater.calls) - require.Zero(t, h.tunnel.ensureCalls) - require.Zero(t, h.cleaner.calls) + require.Empty(t, h.client.revokeRequests) } func cloneRevokeRequest(req *nodev1.RevokeNodeSSHAccessRequest) *nodev1.RevokeNodeSSHAccessRequest { @@ -558,33 +352,6 @@ func testSSHAccess(userID, linuxUser, portID string) *nodev1.SSHAccess { return &nodev1.SSHAccess{UserId: userID, LinuxUser: linuxUser, PortId: portID} } -func recordDisableSSHEvent(events *[]string, event string) { - if events != nil { - *events = append(*events, event) - } -} - -func filterDisableSSHEvents(events []string, prefix string) []string { - var filtered []string - for _, event := range events { - if strings.HasPrefix(event, prefix) { - filtered = append(filtered, event) - } - } - return filtered -} - -func requireOrderedSubsequence(t *testing.T, events []string, expected ...string) { - t.Helper() - next := 0 - for _, event := range events { - if next < len(expected) && event == expected[next] { - next++ - } - } - require.Equal(t, len(expected), next, "events %v do not contain ordered subsequence %v", events, expected) -} - func captureDisableSSHStdout(t *testing.T, run func(*terminal.Terminal) error) (string, error) { t.Helper() reader, writer, err := os.Pipe() diff --git a/pkg/cmd/disablessh/localkeys.go b/pkg/cmd/disablessh/localkeys.go deleted file mode 100644 index 0631f74b..00000000 --- a/pkg/cmd/disablessh/localkeys.go +++ /dev/null @@ -1,208 +0,0 @@ -package disablessh - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path" - "runtime" - "strings" - - "github.com/brevdev/brev-cli/pkg/cmd/register" - breverrors "github.com/brevdev/brev-cli/pkg/errors" -) - -const cleanupHelperArg = "__brev-disable-ssh-cleanup" - -type KeyCleanupResult struct { - AccountsScanned int `json:"accounts_scanned"` - AccountsChanged int `json:"accounts_changed"` - KeysRemoved int `json:"keys_removed"` -} - -type localAccount struct { - Username string - HomeDir string -} - -type localKeyCleaner interface { - RemoveBrevKeys(context.Context) (KeyCleanupResult, error) -} - -func parsePasswd(data []byte) ([]localAccount, error) { - lines := bytes.Split(data, []byte("\n")) - accounts := make([]localAccount, 0, len(lines)) - seenHomes := make(map[string]struct{}, len(lines)) - for i, line := range lines { - line = bytes.TrimSuffix(line, []byte("\r")) - if len(line) == 0 { - continue - } - fields := bytes.Split(line, []byte(":")) - if len(fields) != 7 { - return nil, fmt.Errorf("parse passwd line %d: expected seven fields, got %d", i+1, len(fields)) - } - home := string(fields[5]) - if !path.IsAbs(home) { - return nil, fmt.Errorf("parse passwd line %d: home directory %q is not absolute", i+1, home) - } - if _, ok := seenHomes[home]; ok { - continue - } - seenHomes[home] = struct{}{} - accounts = append(accounts, localAccount{Username: string(fields[0]), HomeDir: home}) - } - return accounts, nil -} - -func stripBrevManagedAuthorizedKeyLines(data []byte) ([]byte, int) { - segments := bytes.SplitAfter(data, []byte("\n")) - cleaned := make([]byte, 0, len(data)) - removed := 0 - for _, segment := range segments { - line := bytes.TrimSuffix(segment, []byte("\n")) - line = bytes.TrimSuffix(line, []byte("\r")) - if register.IsBrevManagedAuthorizedKeysLine(string(line)) { - removed++ - continue - } - cleaned = append(cleaned, segment...) - } - return cleaned, removed -} - -type systemLocalKeyCleaner struct { - listAccounts func(context.Context) ([]localAccount, error) - cleanAccount func(localAccount) (int, error) -} - -func (c systemLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { - accounts, err := c.listAccounts(ctx) - if err != nil { - return KeyCleanupResult{}, fmt.Errorf("enumerate local accounts: %w", err) - } - - var result KeyCleanupResult - var accountErrs []error - for _, account := range accounts { - removed, err := c.cleanAccount(account) - if err != nil { - accountErrs = append(accountErrs, fmt.Errorf("clean Brev keys for account %q at %q: %w", account.Username, account.HomeDir, err)) - continue - } - result.AccountsScanned++ - if removed > 0 { - result.AccountsChanged++ - result.KeysRemoved += removed - } - } - if err := breverrors.Join(accountErrs...); err != nil { - return result, fmt.Errorf("clean one or more local accounts: %w", err) - } - return result, nil -} - -func newSystemLocalKeyCleaner() localKeyCleaner { - return systemLocalKeyCleaner{ - listAccounts: listLocalAccounts, - cleanAccount: cleanLocalAccount, - } -} - -type privilegedCommandRunner interface { - Output(context.Context, string, ...string) ([]byte, error) -} - -type execPrivilegedCommandRunner struct{} - -func (execPrivilegedCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { - output, err := exec.CommandContext(ctx, name, args...).Output() - if err == nil { - return output, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return nil, fmt.Errorf("%w (stderr: %s)", err, strings.TrimSpace(string(exitErr.Stderr))) - } - return nil, fmt.Errorf("execute privileged command: %w", err) -} - -type privilegedLocalKeyCleaner struct { - geteuid func() int - executable func() (string, error) - runner privilegedCommandRunner - direct localKeyCleaner -} - -func (c privilegedLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { - if c.geteuid() == 0 { - result, err := c.direct.RemoveBrevKeys(ctx) - if err != nil { - return result, fmt.Errorf("run direct Brev key cleanup: %w", err) - } - return result, nil - } - executable, err := c.executable() - if err != nil { - return KeyCleanupResult{}, fmt.Errorf("locate Brev executable: %w", err) - } - output, err := c.runner.Output(ctx, "sudo", "-n", executable, cleanupHelperArg) - if err != nil { - return KeyCleanupResult{}, fmt.Errorf("run privileged Brev key cleanup: %w", err) - } - var result KeyCleanupResult - if err := json.Unmarshal(output, &result); err != nil { - return KeyCleanupResult{}, fmt.Errorf("decode privileged Brev key cleanup result: %w", err) - } - return result, nil -} - -func newPrivilegedLocalKeyCleaner() localKeyCleaner { //nolint:unused // Used by the Task 5 disable-ssh command. - return privilegedLocalKeyCleaner{ - geteuid: os.Geteuid, - executable: os.Executable, - runner: execPrivilegedCommandRunner{}, - direct: newSystemLocalKeyCleaner(), - } -} - -// RunLocalKeyCleanupHelper runs the fixed privileged key-cleanup mode when -// selected by args. Normal CLI arguments are ignored. -func RunLocalKeyCleanupHelper(ctx context.Context, args []string, stdout io.Writer) (bool, error) { - return runLocalKeyCleanupHelper(ctx, args, stdout, runtime.GOOS, os.Geteuid, newSystemLocalKeyCleaner()) -} - -func runLocalKeyCleanupHelper( - ctx context.Context, - args []string, - stdout io.Writer, - goos string, - geteuid func() int, - cleaner localKeyCleaner, -) (bool, error) { - if len(args) == 0 || args[0] != cleanupHelperArg { - return false, nil - } - if len(args) != 1 { - return true, fmt.Errorf("privileged Brev key cleanup requires exactly one fixed argument") - } - if goos != "linux" { - return true, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") - } - if geteuid() != 0 { - return true, fmt.Errorf("privileged Brev key cleanup must run as root") - } - result, err := cleaner.RemoveBrevKeys(ctx) - if err != nil { - return true, fmt.Errorf("run local Brev key cleanup: %w", err) - } - if err := json.NewEncoder(stdout).Encode(result); err != nil { - return true, fmt.Errorf("encode privileged Brev key cleanup result: %w", err) - } - return true, nil -} diff --git a/pkg/cmd/disablessh/localkeys_linux.go b/pkg/cmd/disablessh/localkeys_linux.go deleted file mode 100644 index 9470ef28..00000000 --- a/pkg/cmd/disablessh/localkeys_linux.go +++ /dev/null @@ -1,531 +0,0 @@ -//go:build linux - -package disablessh - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path" - "strings" - - "golang.org/x/sys/unix" -) - -const ( - authorizedKeysName = "authorized_keys" - tempFilePrefix = "authorized_keys.brev-cleanup-" -) - -type getentCommandRunner interface { - Output(context.Context, string, ...string) ([]byte, error) -} - -type execGetentCommandRunner struct{} - -func (execGetentCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { - output, err := exec.CommandContext(ctx, name, args...).Output() - if err == nil { - return output, nil - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { - return nil, fmt.Errorf("%w (stderr: %s)", err, strings.TrimSpace(string(exitErr.Stderr))) - } - return nil, err -} - -func resolveGetent(exists func(string) bool) (string, error) { - for _, candidate := range [...]string{"/usr/bin/getent", "/bin/getent"} { - if exists(candidate) { - return candidate, nil - } - } - return "", fmt.Errorf("getent not found at /usr/bin/getent or /bin/getent") -} - -func listLocalAccounts(ctx context.Context) ([]localAccount, error) { - getentPath, err := resolveGetent(func(candidate string) bool { - info, err := os.Stat(candidate) - return err == nil && info.Mode().IsRegular() - }) - if err != nil { - return nil, err - } - return listLocalAccountsWith(ctx, getentPath, execGetentCommandRunner{}) -} - -func listLocalAccountsWith(ctx context.Context, getentPath string, runner getentCommandRunner) ([]localAccount, error) { - output, err := runner.Output(ctx, getentPath, "passwd") - if err != nil { - return nil, fmt.Errorf("run getent passwd: %w", err) - } - accounts, err := parsePasswd(output) - if err != nil { - return nil, fmt.Errorf("parse getent passwd output: %w", err) - } - if len(accounts) == 0 { - return nil, fmt.Errorf("getent passwd returned no local accounts") - } - return accounts, nil -} - -func cleanLocalAccount(account localAccount) (int, error) { - homeFD, err := openAbsoluteDirectory(account.HomeDir) - if err != nil { - if errors.Is(err, unix.ENOENT) { - return 0, nil - } - return 0, fmt.Errorf("open home directory %q: %w", account.HomeDir, err) - } - defer closeDescriptor(homeFD) - - sshFD, err := unix.Openat(homeFD, ".ssh", directoryOpenFlags(), 0) - if err != nil { - if errors.Is(err, unix.ENOENT) { - return 0, nil - } - return 0, fmt.Errorf("open .ssh under home %q: %w", account.HomeDir, err) - } - defer closeDescriptor(sshFD) - - var beforeOpen unix.Stat_t - if err := unix.Fstatat(sshFD, authorizedKeysName, &beforeOpen, unix.AT_SYMLINK_NOFOLLOW); err != nil { - if errors.Is(err, unix.ENOENT) { - return 0, nil - } - return 0, fmt.Errorf("inspect authorized_keys under home %q: %w", account.HomeDir, err) - } - if !isRegular(beforeOpen) { - return 0, fmt.Errorf("authorized_keys under home %q is not a regular file", account.HomeDir) - } - - authorizedKeysFD, err := unix.Openat( - sshFD, - authorizedKeysName, - unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, - 0, - ) - if err != nil { - if errors.Is(err, unix.ENOENT) { - return 0, nil - } - return 0, fmt.Errorf("open authorized_keys under home %q: %w", account.HomeDir, err) - } - authorizedKeysFile := os.NewFile(uintptr(authorizedKeysFD), authorizedKeysName) - if authorizedKeysFile == nil { - closeDescriptor(authorizedKeysFD) - return 0, fmt.Errorf("open authorized_keys under home %q: invalid file descriptor", account.HomeDir) - } - defer func() { _ = authorizedKeysFile.Close() }() - - var opened unix.Stat_t - if err := unix.Fstat(authorizedKeysFD, &opened); err != nil { - return 0, fmt.Errorf("inspect opened authorized_keys under home %q: %w", account.HomeDir, err) - } - if !isRegular(opened) { - return 0, fmt.Errorf("opened authorized_keys under home %q is not a regular file", account.HomeDir) - } - if !sameFileIdentity(beforeOpen, opened) { - return 0, fmt.Errorf("authorized_keys under home %q changed while opening", account.HomeDir) - } - - data, err := io.ReadAll(authorizedKeysFile) - if err != nil { - return 0, fmt.Errorf("read authorized_keys under home %q: %w", account.HomeDir, err) - } - cleaned, removed := stripBrevManagedAuthorizedKeyLines(data) - if removed == 0 { - return 0, nil - } - - if err := replaceAuthorizedKeys(sshFD, authorizedKeysFD, data, cleaned, opened); err != nil { - return 0, fmt.Errorf("replace authorized_keys under home %q: %w", account.HomeDir, err) - } - return removed, nil -} - -func directoryOpenFlags() int { - return unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW -} - -func openAbsoluteDirectory(home string) (int, error) { - if !path.IsAbs(home) { - return -1, fmt.Errorf("path %q is not absolute", home) - } - for _, component := range strings.Split(home, "/") { - if component == ".." { - return -1, fmt.Errorf("path %q contains parent traversal", home) - } - } - - currentFD, err := unix.Open("/", directoryOpenFlags(), 0) - if err != nil { - return -1, fmt.Errorf("open root directory: %w", err) - } - cleaned := path.Clean(home) - for _, component := range strings.Split(strings.TrimPrefix(cleaned, "/"), "/") { - if component == "" || component == "." { - continue - } - nextFD, err := unix.Openat(currentFD, component, directoryOpenFlags(), 0) - if err != nil { - closeDescriptor(currentFD) - return -1, fmt.Errorf("open path component %q: %w", component, err) - } - if err := unix.Close(currentFD); err != nil { - closeDescriptor(nextFD) - return -1, fmt.Errorf("close parent directory before %q: %w", component, err) - } - currentFD = nextFD - } - return currentFD, nil -} - -func isRegular(stat unix.Stat_t) bool { - return stat.Mode&unix.S_IFMT == unix.S_IFREG -} - -func sameFileIdentity(a, b unix.Stat_t) bool { - return a.Dev == b.Dev && a.Ino == b.Ino && a.Mode&unix.S_IFMT == b.Mode&unix.S_IFMT -} - -type replaceAuthorizedKeysHooks struct { - beforeExchange func(sshFD int, tempName string) error -} - -func replaceAuthorizedKeys( - sshFD int, - originalFD int, - originalData []byte, - cleaned []byte, - original unix.Stat_t, -) error { - return replaceAuthorizedKeysWithHooks( - sshFD, - originalFD, - originalData, - cleaned, - original, - replaceAuthorizedKeysHooks{}, - ) -} - -func replaceAuthorizedKeysWithHooks( - sshFD int, - originalFD int, - originalData []byte, - cleaned []byte, - original unix.Stat_t, - hooks replaceAuthorizedKeysHooks, -) (retErr error) { - tempFD, tempName, err := createRandomTempFile(sshFD) - if err != nil { - return err - } - var tempCleanupIdentity unix.Stat_t - tempIdentityKnown := false - if err := unix.Fstat(tempFD, &tempCleanupIdentity); err != nil { - closeDescriptor(tempFD) - return fmt.Errorf("inspect created temporary authorized_keys: %w", err) - } - tempIdentityKnown = true - var tempStat unix.Stat_t - defer func() { - if tempIdentityKnown { - if _, cleanupErr := unlinkNameIfMatches(sshFD, tempName, tempCleanupIdentity); cleanupErr != nil { - retErr = errors.Join(retErr, fmt.Errorf("remove temporary authorized_keys: %w", cleanupErr)) - } - } - closeDescriptor(tempFD) - }() - - if err := writeAll(tempFD, cleaned); err != nil { - return fmt.Errorf("write temporary authorized_keys: %w", err) - } - if err := unix.Fchown(tempFD, int(original.Uid), int(original.Gid)); err != nil { - return fmt.Errorf("restore temporary authorized_keys ownership: %w", err) - } - if err := unix.Fchmod(tempFD, original.Mode&0o7777); err != nil { - return fmt.Errorf("restore temporary authorized_keys mode: %w", err) - } - if err := unix.Fsync(tempFD); err != nil { - return fmt.Errorf("sync temporary authorized_keys: %w", err) - } - if err := unix.Fstat(tempFD, &tempStat); err != nil { - return fmt.Errorf("inspect temporary authorized_keys: %w", err) - } - if !isRegular(tempStat) { - return fmt.Errorf("temporary authorized_keys is not a regular file") - } - - if err := verifyDescriptorState(originalFD, original, originalData); err != nil { - return fmt.Errorf("authorized_keys changed before commit: %w", err) - } - if err := verifyDescriptorState(tempFD, tempStat, cleaned); err != nil { - return fmt.Errorf("temporary authorized_keys changed before commit: %w", err) - } - if err := verifyNameMatches(sshFD, authorizedKeysName, original); err != nil { - return fmt.Errorf("authorized_keys changed before commit: %w", err) - } - if err := verifyNameMatches(sshFD, tempName, tempStat); err != nil { - return fmt.Errorf("temporary authorized_keys changed before commit: %w", err) - } - if hooks.beforeExchange != nil { - if err := hooks.beforeExchange(sshFD, tempName); err != nil { - return fmt.Errorf("run authorized_keys commit hook: %w", err) - } - } - - if err := unix.Renameat2( - sshFD, - tempName, - sshFD, - authorizedKeysName, - unix.RENAME_EXCHANGE, - ); err != nil { - return fmt.Errorf("exchange temporary authorized_keys: %w", err) - } - - postAuthorized, postTemp, verificationErr := verifyExchangedAuthorizedKeys( - sshFD, - originalFD, - tempFD, - tempName, - original, - tempStat, - originalData, - cleaned, - ) - if verificationErr != nil { - rollbackErr := rollbackAuthorizedKeysExchange(sshFD, tempName, postAuthorized, postTemp) - if rollbackErr != nil { - return errors.Join( - fmt.Errorf("authorized_keys changed during commit: %w", verificationErr), - fmt.Errorf("restore authorized_keys exchange: %w", rollbackErr), - ) - } - return fmt.Errorf("authorized_keys changed during commit: %w", verificationErr) - } - - unlinked, err := unlinkNameIfMatches(sshFD, tempName, original) - if err != nil { - return fmt.Errorf("remove exchanged original authorized_keys: %w", err) - } - if !unlinked { - return fmt.Errorf("authorized_keys changed during commit before removing exchanged original") - } - if err := unix.Fsync(sshFD); err != nil { - return fmt.Errorf("sync .ssh directory: %w", err) - } - return nil -} - -func verifyExchangedAuthorizedKeys( - sshFD int, - originalFD int, - tempFD int, - tempName string, - original unix.Stat_t, - temp unix.Stat_t, - originalData []byte, - cleaned []byte, -) (unix.Stat_t, unix.Stat_t, error) { - postAuthorized, authorizedErr := statName(sshFD, authorizedKeysName) - postTemp, tempErr := statName(sshFD, tempName) - var verificationErrs []error - if authorizedErr != nil { - verificationErrs = append(verificationErrs, fmt.Errorf("inspect exchanged authorized_keys: %w", authorizedErr)) - } else if !sameFileIdentity(postAuthorized, temp) { - verificationErrs = append(verificationErrs, fmt.Errorf("exchanged authorized_keys does not match verified temporary file")) - } - if tempErr != nil { - verificationErrs = append(verificationErrs, fmt.Errorf("inspect exchanged original authorized_keys: %w", tempErr)) - } else if !sameFileIdentity(postTemp, original) { - verificationErrs = append(verificationErrs, fmt.Errorf("exchanged original authorized_keys does not match opened file")) - } - if err := verifyDescriptorState(originalFD, original, originalData); err != nil { - verificationErrs = append(verificationErrs, fmt.Errorf("opened original authorized_keys changed: %w", err)) - } - if err := verifyDescriptorState(tempFD, temp, cleaned); err != nil { - verificationErrs = append(verificationErrs, fmt.Errorf("opened temporary authorized_keys changed: %w", err)) - } - return postAuthorized, postTemp, errors.Join(verificationErrs...) -} - -func rollbackAuthorizedKeysExchange( - sshFD int, - tempName string, - postAuthorized unix.Stat_t, - postTemp unix.Stat_t, -) error { - currentAuthorized, authorizedErr := statName(sshFD, authorizedKeysName) - currentTemp, tempErr := statName(sshFD, tempName) - if authorizedErr != nil || tempErr != nil { - var inspectErrs []error - if authorizedErr != nil { - inspectErrs = append(inspectErrs, fmt.Errorf("inspect current authorized_keys before rollback: %w", authorizedErr)) - } - if tempErr != nil { - inspectErrs = append(inspectErrs, fmt.Errorf("inspect current temporary name before rollback: %w", tempErr)) - } - return errors.Join(inspectErrs...) - } - if !sameFileIdentity(currentAuthorized, postAuthorized) || !sameFileIdentity(currentTemp, postTemp) { - return fmt.Errorf("directory entries changed again before rollback") - } - if err := unix.Renameat2( - sshFD, - tempName, - sshFD, - authorizedKeysName, - unix.RENAME_EXCHANGE, - ); err != nil { - return fmt.Errorf("exchange directory entries back: %w", err) - } - if err := verifyNameMatches(sshFD, authorizedKeysName, postTemp); err != nil { - return fmt.Errorf("verify restored authorized_keys: %w", err) - } - if err := verifyNameMatches(sshFD, tempName, postAuthorized); err != nil { - return fmt.Errorf("verify restored temporary name: %w", err) - } - if err := unix.Fsync(sshFD); err != nil { - return fmt.Errorf("sync restored .ssh directory: %w", err) - } - return nil -} - -func verifyDescriptorState(fd int, expected unix.Stat_t, expectedData []byte) error { - if err := verifyDescriptorMetadata(fd, expected); err != nil { - return err - } - data, err := readAllAt(fd) - if err != nil { - return fmt.Errorf("read opened file: %w", err) - } - if !bytes.Equal(data, expectedData) { - return fmt.Errorf("opened file contents changed") - } - return nil -} - -func verifyDescriptorMetadata(fd int, expected unix.Stat_t) error { - var current unix.Stat_t - if err := unix.Fstat(fd, ¤t); err != nil { - return fmt.Errorf("inspect opened file: %w", err) - } - if !isRegular(current) || !sameFileIdentity(current, expected) { - return fmt.Errorf("opened file identity changed") - } - if current.Uid != expected.Uid || current.Gid != expected.Gid || current.Mode&0o7777 != expected.Mode&0o7777 { - return fmt.Errorf("opened file ownership or mode changed") - } - return nil -} - -func verifyNameMatches(dirFD int, name string, expected unix.Stat_t) error { - current, err := statName(dirFD, name) - if err != nil { - return err - } - if !isRegular(current) || !sameFileIdentity(current, expected) { - return fmt.Errorf("%q no longer identifies the verified regular file", name) - } - return nil -} - -func statName(dirFD int, name string) (unix.Stat_t, error) { - var stat unix.Stat_t - if err := unix.Fstatat(dirFD, name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { - return unix.Stat_t{}, err - } - return stat, nil -} - -func unlinkNameIfMatches(dirFD int, name string, expected unix.Stat_t) (bool, error) { - current, err := statName(dirFD, name) - if errors.Is(err, unix.ENOENT) { - return false, nil - } - if err != nil { - return false, err - } - if !sameFileIdentity(current, expected) { - return false, nil - } - if err := unix.Unlinkat(dirFD, name, 0); err != nil { - return false, err - } - return true, nil -} - -func readAllAt(fd int) ([]byte, error) { - const chunkSize = 32 * 1024 - data := make([]byte, 0, chunkSize) - buffer := make([]byte, chunkSize) - for { - n, err := unix.Pread(fd, buffer, int64(len(data))) - if errors.Is(err, unix.EINTR) { - continue - } - if err != nil { - return nil, err - } - if n == 0 { - return data, nil - } - data = append(data, buffer[:n]...) - } -} - -func createRandomTempFile(sshFD int) (int, string, error) { - for range 128 { - random := make([]byte, 16) - if _, err := rand.Read(random); err != nil { - return -1, "", fmt.Errorf("generate temporary authorized_keys name: %w", err) - } - name := tempFilePrefix + hex.EncodeToString(random) - fd, err := unix.Openat( - sshFD, - name, - unix.O_CREAT|unix.O_EXCL|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, - 0o600, - ) - if err == nil { - return fd, name, nil - } - if !errors.Is(err, unix.EEXIST) { - return -1, "", fmt.Errorf("create temporary authorized_keys: %w", err) - } - } - return -1, "", fmt.Errorf("create temporary authorized_keys: exhausted random names") -} - -func writeAll(fd int, data []byte) error { - for len(data) > 0 { - n, err := unix.Write(fd, data) - if errors.Is(err, unix.EINTR) { - continue - } - if err != nil { - return err - } - if n == 0 { - return io.ErrShortWrite - } - data = data[n:] - } - return nil -} - -func closeDescriptor(fd int) { - if fd >= 0 { - _ = unix.Close(fd) - } -} diff --git a/pkg/cmd/disablessh/localkeys_linux_test.go b/pkg/cmd/disablessh/localkeys_linux_test.go deleted file mode 100644 index 85323a4b..00000000 --- a/pkg/cmd/disablessh/localkeys_linux_test.go +++ /dev/null @@ -1,462 +0,0 @@ -//go:build linux - -package disablessh - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "golang.org/x/sys/unix" -) - -type fakeGetentRunner struct { - output []byte - err error - name string - args []string -} - -func (f *fakeGetentRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { - f.name = name - f.args = append([]string(nil), args...) - return append([]byte(nil), f.output...), f.err -} - -func TestResolveGetent_UsesOnlyFixedCandidates(t *testing.T) { - var checked []string - got, err := resolveGetent(func(candidate string) bool { - checked = append(checked, candidate) - return candidate == "/bin/getent" - }) - if err != nil { - t.Fatalf("resolveGetent: %v", err) - } - if got != "/bin/getent" { - t.Fatalf("resolveGetent = %q, want /bin/getent", got) - } - wantChecked := []string{"/usr/bin/getent", "/bin/getent"} - if !reflect.DeepEqual(checked, wantChecked) { - t.Fatalf("checked = %#v, want fixed candidates %#v", checked, wantChecked) - } -} - -func TestListLocalAccountsWith_RunsFixedGetentPasswdAndParsesOutput(t *testing.T) { - data, err := os.ReadFile("testdata/passwd.txt") - if err != nil { - t.Fatal(err) - } - runner := &fakeGetentRunner{output: data} - - accounts, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) - if err != nil { - t.Fatalf("listLocalAccountsWith: %v", err) - } - if runner.name != "/usr/bin/getent" || !reflect.DeepEqual(runner.args, []string{"passwd"}) { - t.Fatalf("getent call = %q %#v, want /usr/bin/getent [passwd]", runner.name, runner.args) - } - if len(accounts) != 4 { - t.Fatalf("accounts = %d, want 4 deduplicated homes", len(accounts)) - } -} - -func TestListLocalAccountsWith_PropagatesGetentFailure(t *testing.T) { - runner := &fakeGetentRunner{err: errors.New("exit 2")} - _, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) - if err == nil || !strings.Contains(err.Error(), "getent passwd") || !strings.Contains(err.Error(), "exit 2") { - t.Fatalf("listLocalAccountsWith() error = %v, want getent failure context", err) - } -} - -func TestListLocalAccountsWith_RejectsEmptyEnumeration(t *testing.T) { - for _, output := range [][]byte{nil, []byte("\n\r\n")} { - runner := &fakeGetentRunner{output: output} - _, err := listLocalAccountsWith(context.Background(), "/usr/bin/getent", runner) - if err == nil || !strings.Contains(err.Error(), "returned no local accounts") { - t.Fatalf("listLocalAccountsWith(%q) error = %v, want empty-enumeration failure", output, err) - } - } -} - -func TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwnership(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - before, err := os.ReadFile("testdata/authorized_keys.before") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(authKeysPath, before, 0o600); err != nil { - t.Fatal(err) - } - if err := os.Chmod(authKeysPath, 0o2640); err != nil { - t.Fatal(err) - } - var beforeStat unix.Stat_t - if err := unix.Stat(authKeysPath, &beforeStat); err != nil { - t.Fatal(err) - } - if got := beforeStat.Mode & 0o7777; got != 0o2640 { - t.Skipf("filesystem cannot establish setgid test precondition: mode = %#o, want %#o", got, uint32(0o2640)) - } - - removed, err := cleanLocalAccount(account) - if err != nil { - t.Fatalf("cleanLocalAccount: %v", err) - } - if removed != 2 { - t.Fatalf("removed = %d, want 2", removed) - } - want, err := os.ReadFile("testdata/authorized_keys.after") - if err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(authKeysPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, want) { - t.Fatalf("authorized_keys = %q, want %q", got, want) - } - var afterStat unix.Stat_t - if err := unix.Stat(authKeysPath, &afterStat); err != nil { - t.Fatal(err) - } - if afterStat.Uid != beforeStat.Uid || afterStat.Gid != beforeStat.Gid { - t.Fatalf("ownership = %d:%d, want %d:%d", afterStat.Uid, afterStat.Gid, beforeStat.Uid, beforeStat.Gid) - } - if gotMode, wantMode := afterStat.Mode&0o7777, beforeStat.Mode&0o7777; gotMode != wantMode { - t.Fatalf("mode = %#o, want full mode %#o", gotMode, wantMode) - } -} - -func TestReplaceAuthorizedKeys_RejectsSubstitutedTempSource(t *testing.T) { - _, authKeysPath := prepareAuthorizedKeys(t, true) - original := []byte("ssh-ed25519 KEEP keep@example.com #brev-portID:old\n") - if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { - t.Fatal(err) - } - sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) - defer closeDescriptor(sshFD) - defer closeDescriptor(originalFD) - - err := replaceAuthorizedKeysWithHooks( - sshFD, - originalFD, - original, - []byte("ssh-ed25519 KEEP keep@example.com\n"), - originalStat, - replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, tempName string) error { - if err := unix.Unlinkat(sshFD, tempName, 0); err != nil { - return err - } - attackerFD, err := unix.Openat( - sshFD, - tempName, - unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, - 0o600, - ) - if err != nil { - return err - } - defer closeDescriptor(attackerFD) - return writeAll(attackerFD, []byte("attacker-controlled source\n")) - }}, - ) - if err == nil || !strings.Contains(err.Error(), "changed during commit") { - t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want source-substitution failure", err) - } - got, readErr := os.ReadFile(authKeysPath) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(got, original) { - t.Fatalf("authorized_keys = %q, want original destination preserved %q", got, original) - } -} - -func TestReplaceAuthorizedKeys_RejectsSubstitutedDestinationWithoutDestroyingIt(t *testing.T) { - _, authKeysPath := prepareAuthorizedKeys(t, true) - original := []byte("ssh-ed25519 OLD old@example.com #brev-portID:old\n") - if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { - t.Fatal(err) - } - sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) - defer closeDescriptor(sshFD) - defer closeDescriptor(originalFD) - replacement := []byte("ssh-ed25519 NEW concurrent@example.com\n") - - err := replaceAuthorizedKeysWithHooks( - sshFD, - originalFD, - original, - []byte("ssh-ed25519 OLD old@example.com\n"), - originalStat, - replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, _ string) error { - const replacementName = "authorized_keys.concurrent-replacement" - replacementFD, err := unix.Openat( - sshFD, - replacementName, - unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, - 0o600, - ) - if err != nil { - return err - } - if err := writeAll(replacementFD, replacement); err != nil { - closeDescriptor(replacementFD) - return err - } - if err := unix.Close(replacementFD); err != nil { - return err - } - return unix.Renameat(sshFD, replacementName, sshFD, authorizedKeysName) - }}, - ) - if err == nil || !strings.Contains(err.Error(), "changed during commit") { - t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want destination-substitution failure", err) - } - got, readErr := os.ReadFile(authKeysPath) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(got, replacement) { - t.Fatalf("authorized_keys = %q, want concurrent replacement preserved %q", got, replacement) - } -} - -func TestReplaceAuthorizedKeys_RejectsInPlaceTempContentMutation(t *testing.T) { - _, authKeysPath := prepareAuthorizedKeys(t, true) - original := []byte("ssh-ed25519 OLD old@example.com #brev-portID:old\n") - if err := os.WriteFile(authKeysPath, original, 0o600); err != nil { - t.Fatal(err) - } - sshFD, originalFD, originalStat := openReplacementTestDescriptors(t, authKeysPath) - defer closeDescriptor(sshFD) - defer closeDescriptor(originalFD) - - err := replaceAuthorizedKeysWithHooks( - sshFD, - originalFD, - original, - []byte("ssh-ed25519 OLD old@example.com\n"), - originalStat, - replaceAuthorizedKeysHooks{beforeExchange: func(sshFD int, tempName string) error { - mutatorFD, err := unix.Openat( - sshFD, - tempName, - unix.O_WRONLY|unix.O_TRUNC|unix.O_CLOEXEC|unix.O_NOFOLLOW, - 0, - ) - if err != nil { - return err - } - defer closeDescriptor(mutatorFD) - return writeAll(mutatorFD, []byte("attacker-mutated bytes\n")) - }}, - ) - if err == nil || !strings.Contains(err.Error(), "changed during commit") { - t.Fatalf("replaceAuthorizedKeysWithHooks() error = %v, want temp-content mutation failure", err) - } - got, readErr := os.ReadFile(authKeysPath) - if readErr != nil { - t.Fatal(readErr) - } - if !bytes.Equal(got, original) { - t.Fatalf("authorized_keys = %q, want original destination restored %q", got, original) - } -} - -func TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - if err := os.WriteFile(authKeysPath, []byte("ssh-ed25519 AAAA_KEEP keep@example.com\n"), 0o600); err != nil { - t.Fatal(err) - } - var before unix.Stat_t - if err := unix.Stat(authKeysPath, &before); err != nil { - t.Fatal(err) - } - - removed, err := cleanLocalAccount(account) - if err != nil { - t.Fatalf("cleanLocalAccount: %v", err) - } - if removed != 0 { - t.Fatalf("removed = %d, want 0", removed) - } - var after unix.Stat_t - if err := unix.Stat(authKeysPath, &after); err != nil { - t.Fatal(err) - } - if before.Dev != after.Dev || before.Ino != after.Ino { - t.Fatalf("inode changed from %d:%d to %d:%d without markers", before.Dev, before.Ino, after.Dev, after.Ino) - } -} - -func TestSystemAuthorizedKeysCleaner_MissingSSHDirectoryIsSuccess(t *testing.T) { - account, _ := prepareAuthorizedKeys(t, false) - removed, err := cleanLocalAccount(account) - if err != nil || removed != 0 { - t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) - } -} - -func TestSystemAuthorizedKeysCleaner_MissingHomeComponentIsSuccess(t *testing.T) { - account := localAccount{Username: "alice", HomeDir: filepath.Join(t.TempDir(), "missing", "alice")} - removed, err := cleanLocalAccount(account) - if err != nil || removed != 0 { - t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) - } -} - -func TestSystemAuthorizedKeysCleaner_MissingAuthorizedKeysIsSuccess(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - if _, err := os.Stat(authKeysPath); !os.IsNotExist(err) { - t.Fatalf("authorized_keys unexpectedly exists: %v", err) - } - removed, err := cleanLocalAccount(account) - if err != nil || removed != 0 { - t.Fatalf("removed, err = %d, %v; want 0, nil", removed, err) - } -} - -func TestSystemAuthorizedKeysCleaner_RejectsSSHDirectorySymlink(t *testing.T) { - root := t.TempDir() - home := filepath.Join(root, "home") - target := filepath.Join(root, "target") - if err := os.MkdirAll(home, 0o700); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(target, 0o700); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, filepath.Join(home, ".ssh")); err != nil { - t.Fatal(err) - } - assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: home}) -} - -func TestSystemAuthorizedKeysCleaner_RejectsAuthorizedKeysSymlink(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - target := filepath.Join(t.TempDir(), "target") - if err := os.WriteFile(target, []byte("ssh-rsa KEEP\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, authKeysPath); err != nil { - t.Fatal(err) - } - assertUnsafeAccountPath(t, account) -} - -func TestSystemAuthorizedKeysCleaner_RejectsIntermediateHomeSymlink(t *testing.T) { - root := t.TempDir() - realParent := filepath.Join(root, "real") - home := filepath.Join(realParent, "alice") - if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { - t.Fatal(err) - } - linkParent := filepath.Join(root, "linked") - if err := os.Symlink(realParent, linkParent); err != nil { - t.Fatal(err) - } - assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: filepath.Join(linkParent, "alice")}) -} - -func TestSystemAuthorizedKeysCleaner_RejectsFinalHomeSymlink(t *testing.T) { - root := t.TempDir() - realHome := filepath.Join(root, "real-home") - if err := os.MkdirAll(filepath.Join(realHome, ".ssh"), 0o700); err != nil { - t.Fatal(err) - } - linkedHome := filepath.Join(root, "linked-home") - if err := os.Symlink(realHome, linkedHome); err != nil { - t.Fatal(err) - } - assertUnsafeAccountPath(t, localAccount{Username: "alice", HomeDir: linkedHome}) -} - -func TestSystemAuthorizedKeysCleaner_RejectsParentTraversal(t *testing.T) { - root := t.TempDir() - account := localAccount{Username: "alice", HomeDir: root + "/missing/../alice"} - assertUnsafeAccountPath(t, account) -} - -func TestSystemAuthorizedKeysCleaner_RejectsFIFOWithoutBlocking(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - if err := unix.Mkfifo(authKeysPath, 0o600); err != nil { - t.Fatal(err) - } - - done := make(chan error, 1) - go func() { - _, err := cleanLocalAccount(account) - done <- err - }() - select { - case err := <-done: - if err == nil { - t.Fatal("cleanLocalAccount() error = nil, want FIFO rejection") - } - case <-time.After(2 * time.Second): - t.Fatal("cleanLocalAccount blocked while inspecting FIFO") - } -} - -func TestSystemAuthorizedKeysCleaner_RejectsNonRegularAuthorizedKeys(t *testing.T) { - account, authKeysPath := prepareAuthorizedKeys(t, true) - if err := os.Mkdir(authKeysPath, 0o700); err != nil { - t.Fatal(err) - } - assertUnsafeAccountPath(t, account) -} - -func prepareAuthorizedKeys(t *testing.T, createSSH bool) (localAccount, string) { - t.Helper() - home := filepath.Join(t.TempDir(), "home", "alice") - if err := os.MkdirAll(home, 0o700); err != nil { - t.Fatal(err) - } - sshDir := filepath.Join(home, ".ssh") - if createSSH { - if err := os.Mkdir(sshDir, 0o700); err != nil { - t.Fatal(err) - } - } - return localAccount{Username: "alice", HomeDir: home}, filepath.Join(sshDir, "authorized_keys") -} - -func openReplacementTestDescriptors(t *testing.T, authorizedKeysPath string) (int, int, unix.Stat_t) { - t.Helper() - sshFD, err := unix.Open(filepath.Dir(authorizedKeysPath), directoryOpenFlags(), 0) - if err != nil { - t.Fatal(err) - } - originalFD, err := unix.Openat( - sshFD, - authorizedKeysName, - unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, - 0, - ) - if err != nil { - closeDescriptor(sshFD) - t.Fatal(err) - } - var originalStat unix.Stat_t - if err := unix.Fstat(originalFD, &originalStat); err != nil { - closeDescriptor(originalFD) - closeDescriptor(sshFD) - t.Fatal(err) - } - return sshFD, originalFD, originalStat -} - -func assertUnsafeAccountPath(t *testing.T, account localAccount) { - t.Helper() - if removed, err := cleanLocalAccount(account); err == nil { - t.Fatalf("removed, err = %d, nil; want unsafe-path rejection", removed) - } -} diff --git a/pkg/cmd/disablessh/localkeys_test.go b/pkg/cmd/disablessh/localkeys_test.go deleted file mode 100644 index 24228e81..00000000 --- a/pkg/cmd/disablessh/localkeys_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package disablessh - -import ( - "bytes" - "context" - "errors" - "os" - "reflect" - "strings" - "testing" -) - -func TestParsePasswd_EnumeratesAndDeduplicatesHomes(t *testing.T) { - data, err := os.ReadFile("testdata/passwd.txt") - if err != nil { - t.Fatal(err) - } - - got, err := parsePasswd(data) - if err != nil { - t.Fatalf("parsePasswd: %v", err) - } - want := []localAccount{ - {Username: "root", HomeDir: "/root"}, - {Username: "alice", HomeDir: "/home/alice"}, - {Username: "svc-agent", HomeDir: "/var/lib/svc-agent"}, - {Username: "bob", HomeDir: "/home/shared"}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("parsePasswd() = %#v, want %#v", got, want) - } -} - -func TestParsePasswd_RejectsMalformedRecord(t *testing.T) { - _, err := parsePasswd([]byte("alice:x:1000:1000:Alice:/home/alice\n")) - if err == nil || !strings.Contains(err.Error(), "line 1") { - t.Fatalf("parsePasswd() error = %v, want line context", err) - } -} - -func TestParsePasswd_RejectsRelativeHome(t *testing.T) { - _, err := parsePasswd([]byte("alice:x:1000:1000:Alice:home/alice:/bin/bash\n")) - if err == nil || !strings.Contains(err.Error(), "line 1") || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("parsePasswd() error = %v, want absolute-home error with line context", err) - } -} - -func TestStripBrevManagedAuthorizedKeyLines_PreservesUnrelatedBytes(t *testing.T) { - before, err := os.ReadFile("testdata/authorized_keys.before") - if err != nil { - t.Fatal(err) - } - want, err := os.ReadFile("testdata/authorized_keys.after") - if err != nil { - t.Fatal(err) - } - - got, removed := stripBrevManagedAuthorizedKeyLines(before) - if removed != 2 { - t.Fatalf("removed = %d, want 2", removed) - } - if !bytes.Equal(got, want) { - t.Fatalf("cleaned bytes = %q, want %q", got, want) - } -} - -func TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes(t *testing.T) { - data := []byte("ssh-ed25519 AAAA_KEEP keep@example.com\r\n\nssh-rsa AAAA_FINAL") - got, removed := stripBrevManagedAuthorizedKeyLines(data) - if removed != 0 { - t.Fatalf("removed = %d, want 0", removed) - } - if !bytes.Equal(got, data) { - t.Fatalf("cleaned bytes = %q, want original %q", got, data) - } -} - -func TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors(t *testing.T) { - accounts := []localAccount{ - {Username: "alice", HomeDir: "/home/alice"}, - {Username: "bob", HomeDir: "/home/bob"}, - {Username: "carol", HomeDir: "/home/carol"}, - } - var cleaned []localAccount - cleaner := systemLocalKeyCleaner{ - listAccounts: func(context.Context) ([]localAccount, error) { return accounts, nil }, - cleanAccount: func(account localAccount) (int, error) { - cleaned = append(cleaned, account) - switch account.Username { - case "alice": - return 0, errors.New("first failure") - case "bob": - return 2, nil - case "carol": - return 0, errors.New("third failure") - default: - return 0, nil - } - }, - } - - got, err := cleaner.RemoveBrevKeys(context.Background()) - if !reflect.DeepEqual(cleaned, accounts) { - t.Fatalf("cleaned accounts = %#v, want every account %#v", cleaned, accounts) - } - want := KeyCleanupResult{AccountsScanned: 1, AccountsChanged: 1, KeysRemoved: 2} - if got != want { - t.Fatalf("result = %#v, want %#v", got, want) - } - if err == nil { - t.Fatal("RemoveBrevKeys() error = nil, want joined account failures") - } - for _, text := range []string{"alice", "/home/alice", "first failure", "carol", "/home/carol", "third failure"} { - if !strings.Contains(err.Error(), text) { - t.Errorf("error %q does not contain %q", err, text) - } - } -} - -type fakeLocalKeyCleaner struct { - result KeyCleanupResult - err error - calls int -} - -func (f *fakeLocalKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) { - f.calls++ - return f.result, f.err -} - -type privilegedCommandCall struct { - name string - args []string -} - -type fakePrivilegedCommandRunner struct { - output []byte - err error - calls []privilegedCommandCall -} - -func (f *fakePrivilegedCommandRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { - f.calls = append(f.calls, privilegedCommandCall{name: name, args: append([]string(nil), args...)}) - return append([]byte(nil), f.output...), f.err -} - -func TestPrivilegedLocalKeyCleaner_RootRunsDirectly(t *testing.T) { - direct := &fakeLocalKeyCleaner{result: KeyCleanupResult{AccountsScanned: 3, KeysRemoved: 2}} - runner := &fakePrivilegedCommandRunner{} - cleaner := privilegedLocalKeyCleaner{ - geteuid: func() int { return 0 }, - executable: func() (string, error) { t.Fatal("executable lookup called as root"); return "", nil }, - runner: runner, - direct: direct, - } - - got, err := cleaner.RemoveBrevKeys(context.Background()) - if err != nil { - t.Fatalf("RemoveBrevKeys: %v", err) - } - if got != direct.result || direct.calls != 1 || len(runner.calls) != 0 { - t.Fatalf("got %#v, direct calls %d, runner calls %#v", got, direct.calls, runner.calls) - } -} - -func TestPrivilegedLocalKeyCleaner_UsesFixedSudoCommandWhenNotRoot(t *testing.T) { - runner := &fakePrivilegedCommandRunner{output: []byte(`{"accounts_scanned":4,"accounts_changed":2,"keys_removed":3}`)} - cleaner := privilegedLocalKeyCleaner{ - geteuid: func() int { return 501 }, - executable: func() (string, error) { return "/opt/brev/bin/brev", nil }, - runner: runner, - direct: &fakeLocalKeyCleaner{}, - } - - got, err := cleaner.RemoveBrevKeys(context.Background()) - if err != nil { - t.Fatalf("RemoveBrevKeys: %v", err) - } - wantResult := KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3} - if got != wantResult { - t.Fatalf("result = %#v, want %#v", got, wantResult) - } - wantCalls := []privilegedCommandCall{{ - name: "sudo", - args: []string{"-n", "/opt/brev/bin/brev", "__brev-disable-ssh-cleanup"}, - }} - if !reflect.DeepEqual(runner.calls, wantCalls) { - t.Fatalf("runner calls = %#v, want %#v", runner.calls, wantCalls) - } -} - -func TestPrivilegedLocalKeyCleaner_RejectsInvalidJSON(t *testing.T) { - cleaner := privilegedLocalKeyCleaner{ - geteuid: func() int { return 501 }, - executable: func() (string, error) { return "/opt/brev/bin/brev", nil }, - runner: &fakePrivilegedCommandRunner{output: []byte("not json")}, - direct: &fakeLocalKeyCleaner{}, - } - - _, err := cleaner.RemoveBrevKeys(context.Background()) - if err == nil || !strings.Contains(err.Error(), "decode privileged Brev key cleanup result") { - t.Fatalf("RemoveBrevKeys() error = %v, want invalid JSON context", err) - } -} - -func TestExecPrivilegedCommandRunner_IncludesStderrOnFailure(t *testing.T) { - _, err := (execPrivilegedCommandRunner{}).Output( - context.Background(), - "/bin/sh", - "-c", - "printf 'sudo denied' >&2; exit 7", - ) - if err == nil || !strings.Contains(err.Error(), "sudo denied") { - t.Fatalf("Output() error = %v, want captured stderr", err) - } -} - -func TestRunLocalKeyCleanupHelper_IgnoresNormalCLIArguments(t *testing.T) { - cleaner := &fakeLocalKeyCleaner{} - var stdout bytes.Buffer - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"join", "--approve"}, &stdout, "linux", func() int { return 0 }, cleaner) - if err != nil || handled { - t.Fatalf("handled, err = %v, %v; want false, nil", handled, err) - } - if cleaner.calls != 0 || stdout.Len() != 0 { - t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) - } -} - -func TestRunLocalKeyCleanupHelper_RequiresExactToken(t *testing.T) { - cleaner := &fakeLocalKeyCleaner{} - var stdout bytes.Buffer - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup-extra"}, &stdout, "linux", func() int { return 0 }, cleaner) - if err != nil || handled { - t.Fatalf("handled, err = %v, %v; want false, nil", handled, err) - } - if cleaner.calls != 0 || stdout.Len() != 0 { - t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) - } -} - -func TestRunLocalKeyCleanupHelper_RejectsExtraArguments(t *testing.T) { - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup", "/home/alice"}, &bytes.Buffer{}, "linux", func() int { return 0 }, &fakeLocalKeyCleaner{}) - if !handled || err == nil || !strings.Contains(err.Error(), "exactly one") { - t.Fatalf("handled, err = %v, %v; want selected argument-count error", handled, err) - } -} - -func TestRunLocalKeyCleanupHelper_RejectsNonRoot(t *testing.T) { - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &bytes.Buffer{}, "linux", func() int { return 1000 }, &fakeLocalKeyCleaner{}) - if !handled || err == nil || !strings.Contains(err.Error(), "root") { - t.Fatalf("handled, err = %v, %v; want selected root error", handled, err) - } -} - -func TestRunLocalKeyCleanupHelper_RejectsNonLinux(t *testing.T) { - cleaner := &fakeLocalKeyCleaner{} - var stdout bytes.Buffer - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &stdout, "darwin", func() int { return 0 }, cleaner) - if !handled || err == nil || !strings.Contains(err.Error(), "only supported on Linux") { - t.Fatalf("handled, err = %v, %v; want selected Linux-only error", handled, err) - } - if cleaner.calls != 0 || stdout.Len() != 0 { - t.Fatalf("cleaner calls = %d, stdout = %q; want no side effects", cleaner.calls, stdout.String()) - } -} - -func TestRunLocalKeyCleanupHelper_EmitsJSON(t *testing.T) { - cleaner := &fakeLocalKeyCleaner{result: KeyCleanupResult{AccountsScanned: 4, AccountsChanged: 2, KeysRemoved: 3}} - var stdout bytes.Buffer - handled, err := runLocalKeyCleanupHelper(context.Background(), []string{"__brev-disable-ssh-cleanup"}, &stdout, "linux", func() int { return 0 }, cleaner) - if err != nil || !handled { - t.Fatalf("handled, err = %v, %v; want true, nil", handled, err) - } - if want := "{\"accounts_scanned\":4,\"accounts_changed\":2,\"keys_removed\":3}\n"; stdout.String() != want { - t.Fatalf("stdout = %q, want JSON only %q", stdout.String(), want) - } -} diff --git a/pkg/cmd/disablessh/localkeys_unsupported.go b/pkg/cmd/disablessh/localkeys_unsupported.go deleted file mode 100644 index 30172522..00000000 --- a/pkg/cmd/disablessh/localkeys_unsupported.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !linux - -package disablessh - -import ( - "context" - "fmt" -) - -func listLocalAccounts(context.Context) ([]localAccount, error) { - return nil, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") -} - -func cleanLocalAccount(localAccount) (int, error) { - return 0, fmt.Errorf("brev disable-ssh local cleanup is only supported on Linux") -} diff --git a/pkg/cmd/disablessh/testdata/.gitattributes b/pkg/cmd/disablessh/testdata/.gitattributes deleted file mode 100644 index 081b797d..00000000 --- a/pkg/cmd/disablessh/testdata/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -authorized_keys.before -text whitespace=cr-at-eol -authorized_keys.after -text whitespace=cr-at-eol diff --git a/pkg/cmd/disablessh/testdata/authorized_keys.after b/pkg/cmd/disablessh/testdata/authorized_keys.after deleted file mode 100644 index 06fed3d6..00000000 --- a/pkg/cmd/disablessh/testdata/authorized_keys.after +++ /dev/null @@ -1,3 +0,0 @@ -from="10.0.0.0/8",no-agent-forwarding ssh-ed25519 AAAA_KEEP keep@example.com - -ssh-ed25519 AAAA_FINAL final@example.com diff --git a/pkg/cmd/disablessh/testdata/authorized_keys.before b/pkg/cmd/disablessh/testdata/authorized_keys.before deleted file mode 100644 index 1285fd87..00000000 --- a/pkg/cmd/disablessh/testdata/authorized_keys.before +++ /dev/null @@ -1,5 +0,0 @@ -from="10.0.0.0/8",no-agent-forwarding ssh-ed25519 AAAA_KEEP keep@example.com -ssh-ed25519 AAAA_CURRENT #brev-portID:port_1,brev-userID:user_1 - -ssh-rsa AAAA_LEGACY # brev-cli user_id=user_2 -ssh-ed25519 AAAA_FINAL final@example.com diff --git a/pkg/cmd/disablessh/testdata/passwd.txt b/pkg/cmd/disablessh/testdata/passwd.txt deleted file mode 100644 index 1e74b11b..00000000 --- a/pkg/cmd/disablessh/testdata/passwd.txt +++ /dev/null @@ -1,5 +0,0 @@ -root:x:0:0:root:/root:/bin/bash -alice:x:1000:1000:Alice:/home/alice:/bin/bash -svc-agent:x:998:998:Service Agent:/var/lib/svc-agent:/usr/sbin/nologin -bob:x:1001:1001:Bob:/home/shared:/bin/zsh -carol:x:1002:1002:Carol:/home/shared:/bin/bash diff --git a/pkg/cmd/enablessh/enablessh_test.go b/pkg/cmd/enablessh/enablessh_test.go index 1beb3a88..1c90df27 100644 --- a/pkg/cmd/enablessh/enablessh_test.go +++ b/pkg/cmd/enablessh/enablessh_test.go @@ -4,10 +4,6 @@ import ( "context" "errors" "net/http/httptest" - "os" - "os/user" - "path/filepath" - "strings" "testing" nodev1connect "buf.build/gen/go/brevdev/devplane/connectrpc/go/devplaneapi/v1/devplaneapiv1connect" @@ -21,207 +17,6 @@ import ( "github.com/brevdev/brev-cli/pkg/terminal" ) -// tempUser returns a *user.User whose HomeDir points to a temporary directory. -func tempUser(t *testing.T) *user.User { - t.Helper() - return &user.User{HomeDir: t.TempDir()} -} - -// readAuthorizedKeys is a test helper that reads ~/.ssh/authorized_keys. -func readAuthorizedKeys(t *testing.T, u *user.User) string { - t.Helper() - data, err := os.ReadFile(filepath.Join(u.HomeDir, ".ssh", "authorized_keys")) - if err != nil { - t.Fatalf("reading authorized_keys: %v", err) - } - return string(data) -} - -// --- RemoveBrevAuthorizedKeys --- - -func Test_RemoveBrevAuthorizedKeys_RemovesTaggedKeys(t *testing.T) { - u := tempUser(t) - sshDir := filepath.Join(u.HomeDir, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - t.Fatal(err) - } - - content := strings.Join([]string{ - "ssh-rsa EXISTING user@host", - "ssh-rsa BREVKEY1 " + register.DevplaneAuthorizedKeysComment("p1", "u1"), - "ssh-ed25519 OTHERKEY admin@server", - "ssh-rsa BREVKEY2 " + register.DevplaneAuthorizedKeysComment("p2", "u2"), - "", - }, "\n") - if err := os.WriteFile(filepath.Join(sshDir, "authorized_keys"), []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - removed, err := register.RemoveBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("RemoveBrevAuthorizedKeys: %v", err) - } - - if len(removed) != 2 { - t.Errorf("expected 2 removed keys, got %d: %v", len(removed), removed) - } - - result := readAuthorizedKeys(t, u) - if strings.Contains(result, "#brev-portID:") { - t.Errorf("brev keys still present:\n%s", result) - } - if !strings.Contains(result, "ssh-rsa EXISTING user@host") { - t.Errorf("non-brev key was removed:\n%s", result) - } - if !strings.Contains(result, "ssh-ed25519 OTHERKEY admin@server") { - t.Errorf("non-brev key was removed:\n%s", result) - } -} - -func Test_RemoveBrevAuthorizedKeys_NoopWhenFileDoesNotExist(t *testing.T) { - u := tempUser(t) - - removed, err := register.RemoveBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("expected no error for missing file, got: %v", err) - } - if len(removed) != 0 { - t.Errorf("expected no removed keys, got %v", removed) - } -} - -func Test_RemoveBrevAuthorizedKeys_NoopWhenNoBrevKeys(t *testing.T) { - u := tempUser(t) - sshDir := filepath.Join(u.HomeDir, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - t.Fatal(err) - } - - original := "ssh-rsa EXISTING user@host\nssh-ed25519 OTHER admin@server\n" - if err := os.WriteFile(filepath.Join(sshDir, "authorized_keys"), []byte(original), 0o600); err != nil { - t.Fatal(err) - } - - removed, err := register.RemoveBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("RemoveBrevAuthorizedKeys: %v", err) - } - if len(removed) != 0 { - t.Errorf("expected no removed keys, got %v", removed) - } - - result := readAuthorizedKeys(t, u) - if result != original { - t.Errorf("file was modified when it shouldn't have been.\nwant:\n%s\ngot:\n%s", original, result) - } -} - -// --- RemoveAuthorizedKey (specific key removal) --- - -func Test_RemoveAuthorizedKey_RemovesOnlyTargetKey(t *testing.T) { - u := tempUser(t) - sshDir := filepath.Join(u.HomeDir, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - t.Fatal(err) - } - - content := strings.Join([]string{ - "ssh-rsa KEEP1 user@host", - "ssh-rsa TARGET " + register.DevplaneAuthorizedKeysComment("p1", "u1"), - "ssh-rsa KEEP2 admin@server", - "", - }, "\n") - if err := os.WriteFile(filepath.Join(sshDir, "authorized_keys"), []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - if err := register.RemoveAuthorizedKey(u, "ssh-rsa TARGET"); err != nil { - t.Fatalf("RemoveAuthorizedKey: %v", err) - } - - result := readAuthorizedKeys(t, u) - if strings.Contains(result, "TARGET") { - t.Errorf("target key still present:\n%s", result) - } - if !strings.Contains(result, "ssh-rsa KEEP1 user@host") { - t.Errorf("unrelated key was removed:\n%s", result) - } - if !strings.Contains(result, "ssh-rsa KEEP2 admin@server") { - t.Errorf("unrelated key was removed:\n%s", result) - } -} - -func Test_RemoveAuthorizedKey_NoopWhenKeyNotPresent(t *testing.T) { - u := tempUser(t) - sshDir := filepath.Join(u.HomeDir, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - t.Fatal(err) - } - - original := "ssh-rsa EXISTING user@host\n" - if err := os.WriteFile(filepath.Join(sshDir, "authorized_keys"), []byte(original), 0o600); err != nil { - t.Fatal(err) - } - - if err := register.RemoveAuthorizedKey(u, "ssh-rsa NOTHERE"); err != nil { - t.Fatalf("RemoveAuthorizedKey: %v", err) - } - - result := readAuthorizedKeys(t, u) - if !strings.Contains(result, "ssh-rsa EXISTING user@host") { - t.Errorf("existing key was removed:\n%s", result) - } -} - -func Test_RemoveAuthorizedKey_NoopCases(t *testing.T) { - tests := []struct { - name string - key string - }{ - {"MissingFile", "ssh-rsa SOMEKEY"}, - {"EmptyKey", ""}, - {"WhitespaceKey", " "}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u := tempUser(t) - if err := register.RemoveAuthorizedKey(u, tt.key); err != nil { - t.Fatalf("expected no error, got: %v", err) - } - }) - } -} - -func Test_RemoveAuthorizedKey_DoesNotRemoveOtherBrevKeys(t *testing.T) { - u := tempUser(t) - sshDir := filepath.Join(u.HomeDir, ".ssh") - if err := os.MkdirAll(sshDir, 0o700); err != nil { - t.Fatal(err) - } - - content := strings.Join([]string{ - "ssh-rsa ALICE_KEY " + register.DevplaneAuthorizedKeysComment("p1", "u1"), - "ssh-rsa BOB_KEY " + register.DevplaneAuthorizedKeysComment("p2", "u2"), - "", - }, "\n") - if err := os.WriteFile(filepath.Join(sshDir, "authorized_keys"), []byte(content), 0o600); err != nil { - t.Fatal(err) - } - - // Remove only Alice's key — Bob's should stay. - if err := register.RemoveAuthorizedKey(u, "ssh-rsa ALICE_KEY"); err != nil { - t.Fatalf("RemoveAuthorizedKey: %v", err) - } - - result := readAuthorizedKeys(t, u) - if strings.Contains(result, "ALICE_KEY") { - t.Errorf("Alice's key still present:\n%s", result) - } - if !strings.Contains(result, "ssh-rsa BOB_KEY") { - t.Errorf("Bob's key was removed:\n%s", result) - } -} - type mockNodeClientFactory struct{ serverURL string } func (m mockNodeClientFactory) NewNodeClient(provider externalnode.TokenProvider, _ string) nodev1connect.ExternalNodeServiceClient { diff --git a/pkg/cmd/register/sshkeys.go b/pkg/cmd/register/sshkeys.go index 2d211b27..5f75c515 100644 --- a/pkg/cmd/register/sshkeys.go +++ b/pkg/cmd/register/sshkeys.go @@ -134,100 +134,12 @@ const ( backoffPrintRound = 500 * time.Millisecond ) -// BrevKeyPrefixLegacy marks keys written by older CLI versions (# brev-cli). -const BrevKeyPrefixLegacy = "# brev-cli" - -// BrevKeyPrefix is an alias for BrevKeyPrefixLegacy (tests and migration). -const BrevKeyPrefix = BrevKeyPrefixLegacy - -const ( - brevPortIDField = "brev-portID:" - brevUserIDField = "brev-userID:" -) - // DevplaneAuthorizedKeysComment is the suffix on Brev-managed authorized_keys lines. // The CLI writes this before GrantNodeSSHAccess so devplane need not modify the file. func DevplaneAuthorizedKeysComment(portID, userID string) string { return fmt.Sprintf("#brev-portID:%s,brev-userID:%s", portID, userID) } -// BrevAuthorizedKey represents a single Brev-managed key found in authorized_keys. -type BrevAuthorizedKey struct { - Line string // full line from authorized_keys - KeyContent string // key type + material (and optional ssh comment), without brev suffix - PortID string // from devplane #brev-portID:... - UserID string // from devplane brev-userID:... or legacy user_id= -} - -// IsBrevManagedAuthorizedKeysLine reports whether a line was managed by a -// current or legacy Brev CLI SSH flow. -func IsBrevManagedAuthorizedKeysLine(line string) bool { - return strings.Contains(line, BrevKeyPrefixLegacy) || strings.Contains(line, "#brev-portID:") -} - -func parseBrevAuthorizedKeyLine(trimmed string) BrevAuthorizedKey { - bk := BrevAuthorizedKey{Line: trimmed} - - if idx := strings.Index(trimmed, "#brev-portID:"); idx >= 0 { - bk.KeyContent = strings.TrimSpace(trimmed[:idx]) - tag := trimmed[idx+1:] - for _, part := range strings.Split(tag, ",") { - part = strings.TrimSpace(part) - switch { - case strings.HasPrefix(part, brevPortIDField): - bk.PortID = strings.TrimPrefix(part, brevPortIDField) - case strings.HasPrefix(part, brevUserIDField): - bk.UserID = strings.TrimPrefix(part, brevUserIDField) - } - } - return bk - } - - if idx := strings.Index(trimmed, " "+BrevKeyPrefixLegacy); idx >= 0 { - bk.KeyContent = strings.TrimSpace(trimmed[:idx]) - tag := trimmed[idx+1:] - if uidIdx := strings.Index(tag, "user_id="); uidIdx >= 0 { - rest := tag[uidIdx+len("user_id="):] - if spIdx := strings.Index(rest, " "); spIdx >= 0 { - bk.UserID = rest[:spIdx] - } else { - bk.UserID = rest - } - } - return bk - } - - bk.KeyContent = trimmed - return bk -} - -// ListBrevAuthorizedKeys reads ~/.ssh/authorized_keys and returns Brev-managed lines. -func ListBrevAuthorizedKeys(u *user.User) ([]BrevAuthorizedKey, error) { - authKeysPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys") - - data, err := os.ReadFile(authKeysPath) // #nosec G304 - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading authorized_keys: %w", err) - } - - var keys []BrevAuthorizedKey - for _, line := range strings.Split(string(data), "\n") { - if !IsBrevManagedAuthorizedKeysLine(line) { - continue - } - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - keys = append(keys, parseBrevAuthorizedKeyLine(trimmed)) - } - - return keys, nil -} - // RemoveAuthorizedKeyLine removes an exact line from authorized_keys. func RemoveAuthorizedKeyLine(u *user.User, line string) error { line = strings.TrimSpace(line) @@ -481,68 +393,3 @@ func InstallAuthorizedKey(u *user.User, pubKey, portID, brevUserID string) (bool return true, nil } - -// RemoveAuthorizedKey removes a specific public key from the user's -// ~/.ssh/authorized_keys. It matches the key content regardless of whether -// the brev-cli comment tag is present. -func RemoveAuthorizedKey(u *user.User, pubKey string) error { - pubKey = strings.TrimSpace(pubKey) - if pubKey == "" { - return nil - } - - authKeysPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys") - - existing, err := os.ReadFile(authKeysPath) // #nosec G304 - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("reading authorized_keys: %w", err) - } - - var kept []string - for _, line := range strings.Split(string(existing), "\n") { - if strings.Contains(line, pubKey) { - continue - } - kept = append(kept, line) - } - - result := strings.Join(kept, "\n") - if err := os.WriteFile(authKeysPath, []byte(result), 0o600); err != nil { - return fmt.Errorf("writing authorized_keys: %w", err) - } - return nil -} - -// RemoveBrevAuthorizedKeys removes all Brev-managed SSH keys from authorized_keys. -func RemoveBrevAuthorizedKeys(u *user.User) ([]string, error) { - authKeysPath := filepath.Join(u.HomeDir, ".ssh", "authorized_keys") - - existing, err := os.ReadFile(authKeysPath) // #nosec G304 - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading authorized_keys: %w", err) - } - - var kept []string - var removed []string - for _, line := range strings.Split(string(existing), "\n") { - if IsBrevManagedAuthorizedKeysLine(line) { - if trimmed := strings.TrimSpace(line); trimmed != "" { - removed = append(removed, trimmed) - } - continue - } - kept = append(kept, line) - } - - result := strings.Join(kept, "\n") - if err := os.WriteFile(authKeysPath, []byte(result), 0o600); err != nil { - return nil, fmt.Errorf("writing authorized_keys: %w", err) - } - return removed, nil -} diff --git a/pkg/cmd/register/sshkeys_test.go b/pkg/cmd/register/sshkeys_test.go index b9db7e47..52d0ef20 100644 --- a/pkg/cmd/register/sshkeys_test.go +++ b/pkg/cmd/register/sshkeys_test.go @@ -43,109 +43,6 @@ func TestDevplaneAuthorizedKeysComment(t *testing.T) { } } -func TestIsBrevManagedAuthorizedKeysLine(t *testing.T) { - tests := []struct { - name string - line string - want bool - }{ - {name: "current marker", line: "ssh-ed25519 AAAA #brev-portID:port_1,brev-userID:user_1", want: true}, - {name: "legacy marker", line: "ssh-rsa AAAA # brev-cli user_id=user_1", want: true}, - {name: "unrelated key", line: "ssh-rsa AAAA user@example.com", want: false}, - {name: "blank line", line: "", want: false}, - {name: "unrelated comment", line: "# managed by another tool", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := IsBrevManagedAuthorizedKeysLine(tt.line); got != tt.want { - t.Fatalf("IsBrevManagedAuthorizedKeysLine(%q) = %v, want %v", tt.line, got, tt.want) - } - }) - } -} - -func TestListBrevAuthorizedKeys_ParsesDevplaneFormat(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, strings.Join([]string{ - "ssh-rsa EXISTING user@host", - "ssh-ed25519 AAAA_ALICE user@a.com " + DevplaneAuthorizedKeysComment("port_1", "user_1"), - "ssh-rsa AAAA_BOB " + DevplaneAuthorizedKeysComment("port_2", "user_2"), - "", - }, "\n")) - - keys, err := ListBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("ListBrevAuthorizedKeys: %v", err) - } - if len(keys) != 2 { - t.Fatalf("expected 2 keys, got %d", len(keys)) - } - if keys[0].PortID != "port_1" || keys[0].UserID != "user_1" { - t.Errorf("key[0]: port=%q user=%q", keys[0].PortID, keys[0].UserID) - } - if keys[1].PortID != "port_2" || keys[1].UserID != "user_2" { - t.Errorf("key[1]: port=%q user=%q", keys[1].PortID, keys[1].UserID) - } -} - -func TestListBrevAuthorizedKeys_ParsesLegacyFormat(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, "ssh-ed25519 AAAA_OLD # brev-cli user_id=uid_42\n") - - keys, err := ListBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("ListBrevAuthorizedKeys: %v", err) - } - if len(keys) != 1 { - t.Fatalf("expected 1 key, got %d", len(keys)) - } - if keys[0].UserID != "uid_42" { - t.Errorf("expected user_id uid_42, got %q", keys[0].UserID) - } -} - -func TestListBrevAuthorizedKeys_MixedFormats(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, strings.Join([]string{ - "ssh-rsa AAAA_LEGACY # brev-cli", - "ssh-rsa NONBREV user@host", - "ssh-ed25519 AAAA_NEW " + DevplaneAuthorizedKeysComment("p1", "uid_42"), - "", - }, "\n")) - - keys, err := ListBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("ListBrevAuthorizedKeys: %v", err) - } - if len(keys) != 2 { - t.Fatalf("expected 2 brev keys, got %d", len(keys)) - } -} - -func TestListBrevAuthorizedKeys_NoFile(t *testing.T) { - u := tempUser(t) - keys, err := ListBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("expected no error for missing file, got: %v", err) - } - if len(keys) != 0 { - t.Errorf("expected 0 keys, got %d", len(keys)) - } -} - -func TestListBrevAuthorizedKeys_NoBrevKeys(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, "ssh-rsa NONBREV user@host\n") - keys, err := ListBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("ListBrevAuthorizedKeys: %v", err) - } - if len(keys) != 0 { - t.Errorf("expected 0 brev keys, got %d", len(keys)) - } -} - func TestRemoveAuthorizedKeyLine_RemovesExactLine(t *testing.T) { u := tempUser(t) line := "ssh-ed25519 REMOVE " + DevplaneAuthorizedKeysComment("p1", "user_1") @@ -163,43 +60,6 @@ func TestRemoveAuthorizedKeyLine_RemovesExactLine(t *testing.T) { } } -func TestRemoveBrevAuthorizedKeys_DevplaneLines(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, strings.Join([]string{ - "ssh-rsa KEEP user@host", - "ssh-rsa BREV1 " + DevplaneAuthorizedKeysComment("p1", "u1"), - "ssh-rsa BREV2 " + DevplaneAuthorizedKeysComment("p2", "u2"), - "", - }, "\n")) - - removed, err := RemoveBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("RemoveBrevAuthorizedKeys: %v", err) - } - if len(removed) != 2 { - t.Fatalf("expected 2 removed, got %d", len(removed)) - } - result := readKeys(t, u) - if strings.Contains(result, "#brev-portID:") { - t.Errorf("brev keys remain:\n%s", result) - } - if !strings.Contains(result, "KEEP") { - t.Error("non-brev key was removed") - } -} - -func TestRemoveBrevAuthorizedKeys_LegacyLines(t *testing.T) { - u := tempUser(t) - seedKeys(t, u, "ssh-rsa BREVKEY "+BrevKeyPrefixLegacy+"\n") - removed, err := RemoveBrevAuthorizedKeys(u) - if err != nil { - t.Fatalf("RemoveBrevAuthorizedKeys: %v", err) - } - if len(removed) != 1 { - t.Fatalf("expected 1 removed, got %d", len(removed)) - } -} - func TestInstallAuthorizedKey_AppendsDevplaneComment(t *testing.T) { u := tempUser(t) pub := "ssh-rsa AAAA testkey user@example.com" @@ -265,18 +125,6 @@ func TestInstallAuthorizedKey_secondPortAppendsNewLine(t *testing.T) { } } -func TestRemoveAuthorizedKey_ByPublicKeyMaterial(t *testing.T) { - u := tempUser(t) - pub := "ssh-rsa AAAA testkey" - seedKeys(t, u, pub+" user@host "+DevplaneAuthorizedKeysComment("p1", "u1")+"\n") - if err := RemoveAuthorizedKey(u, pub); err != nil { - t.Fatal(err) - } - if strings.Contains(readKeys(t, u), "AAAA") { - t.Fatal("key material should be removed") - } -} - // --- PromptSSHPort --- func TestPromptSSHPort(t *testing.T) { diff --git a/pkg/sudo/sudo_test.go b/pkg/sudo/sudo_test.go index ba313faf..0ed02b51 100644 --- a/pkg/sudo/sudo_test.go +++ b/pkg/sudo/sudo_test.go @@ -31,7 +31,7 @@ func TestSystemGater_UncachedNonInteractiveSudoFailureIsReturned(t *testing.T) { stdin: stdin, } - err = gater.Gate(terminal.New(), sudoTestConfirmer{}, "Node-wide Brev SSH cleanup", true) + err = gater.Gate(terminal.New(), sudoTestConfirmer{}, "Leave Brev network", true) require.ErrorIs(t, err, probeErr) require.ErrorContains(t, err, "sudo authentication unavailable without an interactive terminal") require.Equal(t, 1, runCalls) From 5b6f8f0d256bcb428c3ec5d95d046481b7a4253f Mon Sep 17 00:00:00 2001 From: Pratik Patel Date: Fri, 14 Aug 2026 16:00:44 -0700 Subject: [PATCH 23/23] simplifications --- .gitignore | 6 +- .../2026-08-07-byon-network-ssh-separation.md | 1254 ----------------- ...8-07-byon-network-ssh-separation-design.md | 455 ------ pkg/cmd/cmd_test.go | 35 - 4 files changed, 5 insertions(+), 1745 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md delete mode 100644 docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md diff --git a/.gitignore b/.gitignore index 1276cd3d..2d833194 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,8 @@ devworkspace/** test.txt test2.txt homebrew-brev -flake-explorations \ No newline at end of file +flake-explorations + +# AI +/docs/superpowers/plans/ +/docs/superpowers/specs/ \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md b/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md deleted file mode 100644 index 17945c0b..00000000 --- a/docs/superpowers/plans/2026-08-07-byon-network-ssh-separation.md +++ /dev/null @@ -1,1254 +0,0 @@ -# BYON Network and SSH Separation Implementation Plan - -> **Archived; do not execute.** This plan records the original implementation. -> Its `disable-ssh` local-key cleanup, privileged-helper, sudo, and fixture-heavy -> testing sections are superseded by the -> [living design spec](../specs/2026-08-07-byon-network-ssh-separation-design.md). -> The current contract revokes backend-tracked grants only and attempts the -> invoking Brev user's records last. The remaining steps are retained solely as -> historical context. - -**Goal:** Make `join`/`leave` own BYON NetBird membership, make `enable-ssh`/`disable-ssh` own Brev-managed SSH credentials, and retain `register`/`deregister` only as deprecated aliases. - -**Architecture:** Keep persisted registration and shared external-node helpers in `pkg/cmd/register`, add one strict reconnecting NetBird primitive, and put node-wide SSH revocation plus privileged local-key cleanup in a new `pkg/cmd/disablessh` package. The SSH commands validate existing membership before mutation; teardown commands preserve retry state and never cross the membership/credential boundary. - -**Tech Stack:** Go 1.25, Cobra, ConnectRPC/protobuf, `golang.org/x/sys/unix` for Linux descriptor-safe file operations, standard-library JSON/process APIs, and the repository's existing terminal, sudo, store, and error packages. - -## Global Constraints - -- Implement only in `/Users/pratpatel/code/brev-cli-byon-network-join` on branch `codex/byon-network-join`. -- Treat `docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md` as the approved behavioral contract. -- Keep the internal `register` and `deregister` package names, `DeviceRegistration`, the registration file, backend RPC/proto shapes, organization selection, and the default Brev network unchanged. -- `join` must not prompt for SSH, resolve a Linux user, open a port, write `authorized_keys`, or grant SSH access. -- `enable-ssh` may reconnect an existing tunnel, but must never call `AddNode`, choose an organization, or create local registration. -- `disable-ssh` must not remove the node, close ports, stop sshd, uninstall NetBird, or delete registration. It removes all backend SSH-access tuples first and only then sweeps every local account for Brev-tagged keys. -- `leave` must not revoke SSH tuples or edit keys. It removes the node, uninstalls NetBird using the existing semantics, and deletes registration last. -- Keep `grant-ssh` and `revoke-ssh` behavior unchanged. -- All alias and safety warnings go to Cobra stderr; ordinary progress and success output continue through `terminal.Terminal`. -- Do not report success after partial teardown. Wrap errors with operation and tuple/account context and preserve their causes. -- Use TDD for every task: add the focused failing test, run it to observe the expected failure, implement the smallest behavior, rerun, then commit. -- Run `gofmt` on every touched Go file. Do not attribute the known macOS baseline failures in Linux e2e setup, JetBrains Gateway detection, or WSL store tests to this work. - -## File Map - -### Modify - -- `pkg/cmd/register/providers.go`: strict NetBird connection contract and injectable command runner. -- `pkg/cmd/register/register.go`: canonical `join`, `register` alias, hidden legacy flag, and membership-only orchestration. -- `pkg/cmd/register/register_test.go`: join surface, compatibility, and no-SSH regression coverage. -- `pkg/cmd/register/device_registration_store.go`: `brev join` recovery guidance. -- `pkg/cmd/register/device_registration_store_test.go`: guidance assertion. -- `pkg/cmd/register/sshkeys.go`: export the exact Brev-marker predicate for the privileged cleanup package. -- `pkg/cmd/register/sshkeys_test.go`: marker predicate coverage. -- `pkg/cmd/enablessh/enablessh.go`: hard joined-node and strict-tunnel preconditions. -- `pkg/cmd/enablessh/enablessh_test.go`: orchestration ordering and no-mutation tests. -- `pkg/cmd/deregister/deregister.go`: canonical `leave`, alias warning, warnings, and retry-safe membership-only teardown. -- `pkg/cmd/deregister/deregister_test.go`: alias, warning, ordering, retry, and failure tests. -- `pkg/cmd/cmd.go`: canonical root wiring and `disable-ssh` registration. -- `pkg/cmd/cmd_test.go`: root command/alias surface. -- `main.go`: dispatch the fixed privileged cleanup helper before normal CLI initialization. -- `README.md`, `CHANGELOG.md`, `.agents/skills/brev-cli/SKILL.md`, and `.agents/skills/brev-cli/reference/commands.md`: user guidance and release notes. - -### Create - -- `pkg/cmd/register/providers_test.go`: strict NetBird connection tests. -- `pkg/cmd/register/node.go` and `pkg/cmd/register/node_test.go`: shared registered-node lookup. -- `pkg/cmd/disablessh/disablessh.go` and `pkg/cmd/disablessh/disablessh_test.go`: public node-wide disable command. -- `pkg/cmd/disablessh/localkeys.go` and `pkg/cmd/disablessh/localkeys_test.go`: OS-neutral account parsing, byte filtering, helper protocol, and sudo runner. -- `pkg/cmd/disablessh/localkeys_linux.go` and `pkg/cmd/disablessh/localkeys_linux_test.go`: Linux NSS enumeration and secure `authorized_keys` rewrite. -- `pkg/cmd/disablessh/localkeys_unsupported.go`: unsupported-platform implementation for non-Linux builds. -- `pkg/cmd/disablessh/testdata/passwd.txt`, `authorized_keys.before`, and `authorized_keys.after`: deterministic cleanup fixtures. -- `docs/BYON.md`: explicit onboarding and retirement workflow. - ---- - -## Task 1: Add a Strict, Reconnecting NetBird Connection Primitive - -**Files:** - -- Modify: `pkg/cmd/register/providers.go` -- Modify: `pkg/cmd/register/register.go` -- Modify: `pkg/cmd/register/register_test.go` -- Modify: `pkg/cmd/deregister/deregister_test.go` -- Create: `pkg/cmd/register/providers_test.go` - -- [ ] **Step 1: Write the command-runner and connection tests** - -Add a scripted runner to `providers_test.go` that records exact commands and supplies queued output/errors: - -```go -type netBirdCall struct { - name string - args []string -} - -type netBirdResult struct { - output []byte - err error -} - -type fakeNetBirdCommandRunner struct { - results []netBirdResult - fallback netBirdResult - calls []netBirdCall -} - -func (f *fakeNetBirdCommandRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { - f.calls = append(f.calls, netBirdCall{name: name, args: append([]string(nil), args...)}) - if len(f.results) == 0 { - return append([]byte(nil), f.fallback.output...), f.fallback.err - } - result := f.results[0] - f.results = f.results[1:] - return append([]byte(nil), result.output...), result.err -} - -func (f *fakeNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { - _, err := f.Output(ctx, name, args...) - return err -} -``` - -Cover these behaviors with `connectTimeout: 10 * time.Millisecond` and `pollInterval: time.Millisecond`. Set a sticky disconnected or error fallback in timeout tests so polling cannot exhaust a scripted slice: - -- `TestNetbirdEnsureConnected_AlreadyConnectedDoesNotReconnect`: active service and `Management: Connected`; no `sudo` call of any kind. -- `TestNetbirdEnsureConnected_StartsInactiveService`: inactive service calls `sudo systemctl start netbird` before status. -- `TestNetbirdEnsureConnected_ReconnectsAndWaitsForConfirmation`: disconnected status calls `sudo netbird up`, observes another disconnected status, then succeeds only on connected status. -- `TestNetbirdEnsureConnected_ReconnectFailure`: `netbird up` failure is returned with `failed to reconnect Brev tunnel` context. -- `TestNetbirdEnsureConnected_StatusNeverConfirmsConnection`: timeout returns `Brev tunnel connection was not confirmed`. -- `TestNetbirdEnsureConnected_StatusErrorsAreNotSuccess`: repeated status errors time out and preserve the last status failure. - -- [ ] **Step 2: Run the focused tests and observe the compile failure** - -Run: - -```bash -go test ./pkg/cmd/register -run '^TestNetbirdEnsureConnected_' -count=1 -``` - -Expected: FAIL because `Netbird` has no injected runner or `EnsureConnected` method. - -- [ ] **Step 3: Introduce the strict connector contract and runner** - -Replace the old permissive `EnsureRunning` contract with: - -```go -type NetBirdConnector interface { - EnsureConnected(context.Context) error -} - -type NetBirdManager interface { - NetBirdConnector - Install() error - Uninstall() error -} -``` - -In `providers.go`, make the zero value production-safe: - -```go -const ( - defaultNetBirdConnectTimeout = 30 * time.Second - defaultNetBirdPollInterval = 500 * time.Millisecond -) - -type netBirdCommandRunner interface { - Output(context.Context, string, ...string) ([]byte, error) - Run(context.Context, string, ...string) error -} - -type execNetBirdCommandRunner struct{} - -func (execNetBirdCommandRunner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { - return exec.CommandContext(ctx, name, args...).Output() -} - -func (execNetBirdCommandRunner) Run(ctx context.Context, name string, args ...string) error { - cmd := exec.CommandContext(ctx, name, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -type Netbird struct { - runner netBirdCommandRunner - connectTimeout time.Duration - pollInterval time.Duration -} -``` - -Add private default accessors so existing `Netbird{}` construction still works. Implement `EnsureConnected(ctx)` in this order: - -1. Check `systemctl is-active netbird`; when inactive or errored, run `sudo systemctl start netbird`. -2. Run `netbird status`; return immediately only when `netbirdManagementConnected` is true. -3. Otherwise run `sudo netbird up`. -4. Poll `netbird status` until it positively reports `Management: Connected`, the caller cancels, or the bounded timeout expires. -5. Treat status-command errors as unconfirmed status, remember the latest error, and include it in the timeout error. - -Move `netbirdManagementConnected` from `register.go` beside this implementation. Start the bounded `context.WithTimeout` only after any interactive `sudo` start/up command returns, so the 30-second positive-confirmation window does not cut off a password prompt. Use a timer/ticker for polling and do not sleep unconditionally in tests. - -- [ ] **Step 4: Make existing-registration reconciliation use the strict primitive** - -Update `checkExistingRegistration` so backend `CONNECTED` status no longer returns before checking the local tunnel. Always call: - -```go -if err := deps.netbird.EnsureConnected(ctx); err != nil { - t.Vprintf(" %s\n", t.Yellow(fmt.Sprintf("Warning: %v", err))) -} else { - t.Vprint(t.Green(" Brev tunnel is connected.")) -} -``` - -Retain warning-only behavior for this already-joined reconciliation path. Add `TestCheckExistingRegistration_ReconcilesLocalTunnel` by calling the existing helper directly, and update every `mockNetBirdManager` in both `register_test.go` and `deregister_test.go` with `EnsureConnected(context.Context) error` so the repository compiles between tasks. Task 2 may rename the test alongside the user-facing join orchestration. - -- [ ] **Step 5: Run tests and format** - -Run: - -```bash -gofmt -w pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/deregister/deregister_test.go -go test ./pkg/cmd/register ./pkg/cmd/deregister -run 'Test(NetbirdEnsureConnected|CheckExistingRegistration)' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/deregister/deregister_test.go -git commit -m "feat: require confirmed Brev tunnel connectivity" -``` - - ---- - -## Task 2: Make `join` Canonical and Remove SSH from Membership Setup - -**Files:** - -- Modify: `pkg/cmd/register/register.go` -- Modify: `pkg/cmd/register/register_test.go` -- Modify: `pkg/cmd/register/device_registration_store.go` -- Modify: `pkg/cmd/register/device_registration_store_test.go` -- Modify: `pkg/cmd/cmd.go` -- Modify: `pkg/cmd/cmd_test.go` - -- [ ] **Step 1: Add command-surface and compatibility tests** - -Add tests with a parent Cobra command so both canonical and alias lookup execute the same command: - -```go -func TestNewCmdJoin_CommandSurface(t *testing.T) { - cmd := NewCmdJoin(testTerminal(t), panicRegisterStore{}) - root := &cobra.Command{Use: "brev"} - root.AddCommand(cmd) - resolved, _, err := root.Find([]string{"register"}) - require.NoError(t, err) - require.Equal(t, "join", cmd.Name()) - require.Equal(t, []string{"register"}, cmd.Aliases) - require.Same(t, cmd, resolved) - require.Error(t, cmd.Args(cmd, []string{"unexpected"})) - require.True(t, cmd.Flags().Lookup("ssh-port").Hidden) -} -``` - -Also add: - -- `TestNewCmdJoin_RegisterAliasWarnsOnExecution`: `register` writes the approved two-line warning to `cmd.SetErr(&stderr)` and then invokes the join handler. -- `TestNewCmdJoin_HelpDoesNotWarn`: `register --help` writes no deprecation warning. -- Table-driven `TestNewCmdJoin_LegacySSHPortFailsBeforeSideEffects` for `join --ssh-port 22`, `join -p 22`, `register --ssh-port 22`, and `register -p 22`; assert the exact migration error and zero platform, sudo, auth, NetBird, RPC, and persistence calls. -- `TestRunJoin_InteractivePromptsOnlyForMembership`: record prompts and assert there is no SSH or port prompt. -- `TestRunJoin_DoesNotOpenPortOrGrantSSH`: a successful join performs AddNode/save/setup but zero OpenPort and GrantNodeSSHAccess calls, and output contains `brev enable-ssh`. -- `Test_LoadRegistration_FailsWhenMissing`: assert the error contains `brev join` and not `brev register`. - -- [ ] **Step 2: Run the new tests and observe the expected failure** - -```bash -go test ./pkg/cmd/register ./pkg/cmd -run 'Test(NewCmdJoin|RunJoin|LoadRegistration|NewBrevCommand_BYON)' -count=1 -``` - -Expected: FAIL because `NewCmdJoin` and the canonical root command do not exist and registration still owns SSH. - -- [ ] **Step 3: Rename the user-facing registration orchestration** - -Keep package and storage terminology intact, but rename these symbols: - -```go -type joinOpts struct { - interactive bool - name string - orgName string - skipConfirm bool -} - -type joinPrompter interface { - terminal.Confirmer - terminal.Selector - Input(terminal.PromptContent) string -} - -type joinDeps struct { - platform externalnode.PlatformChecker - prompter joinPrompter - gater sudo.Gater - netbird NetBirdManager - setupRunner SetupRunner - nodeClients externalnode.NodeClientFactory - hardwareProfiler HardwareProfiler - registrationStore RegistrationStore -} - -func NewCmdJoin(t *terminal.Terminal, store RegisterStore) *cobra.Command -func runJoin(ctx context.Context, t *terminal.Terminal, store RegisterStore, opts joinOpts, deps joinDeps) error -func runJoinSteps(ctx context.Context, t *terminal.Terminal, store RegisterStore, name string, org *entity.Organization, deps joinDeps) error -``` - -Add `TerminalPrompter.Input` as a thin wrapper over `terminal.PromptGetInput`, consolidate the confirmer/selector/input dependency behind `joinPrompter`, and update existing tests/mocks to the new names. `defaultJoinDeps` carries forward the current real platform, sudo gate, NetBird, setup runner, node client, hardware profiler, and registration store. - -Use canonical Cobra metadata: - -```go -Use: "join", -Aliases: []string{"register"}, -Short: "Join this device to a Brev network", -Args: cobra.NoArgs, -``` - -Keep `--name/-n`, `--org/-o`, and `--approve`. Bind `--ssh-port/-p` only as a hidden integer compatibility flag. In `RunE`, before constructing dependencies or calling `runJoin`, use `cmd.Flags().Changed("ssh-port")` so explicit zero also fails: - -```go -if cmd.CalledAs() == "register" { - fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev register" is deprecated; use "brev join" instead.`) - fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer enables SSH; run "brev enable-ssh" separately.`) -} -if cmd.Flags().Changed("ssh-port") { - return fmt.Errorf("--ssh-port is no longer supported by brev join or brev register; run brev join, then run brev enable-ssh on the joined machine") -} -``` - -Compute interactive mode only from `nameFlag == "" && orgFlag == ""`. Update long help, examples, confirmation text, progress, success text, Linux-platform error, and rejoin guidance to `join`/`leave` terminology. - -- [ ] **Step 4: Delete the SSH tail from join** - -Delete `sshPort` from options and remove: - -- the SSH enable confirmation; -- `user.Current()` from the join path; -- `grantSSHAccessWithPort` and `grantSSHAccess` from `register.go`; -- the registration-only SSH retry/OpenPort test cases now duplicated by `sshkeys` and `sshkeys_port_resolve` coverage. - -Change the successful tail to: - -```go -if err := runJoinSteps(ctx, t, s, name, org, deps); err != nil { - return err -} -t.Vprint("") -t.Vprint("SSH access was not enabled. To enable it for your user, run: brev enable-ssh") -return nil -``` - -Call `s.GetCurrentUser()` for authentication without retaining a `brevUser`, because membership setup no longer grants SSH. - -- [ ] **Step 5: Update recovery guidance and root wiring** - -Change the missing-registration error to: - -```go -return nil, breverrors.New("device registration not found, run 'brev join' first") -``` - -In `pkg/cmd/cmd.go`, register only `register.NewCmdJoin(t, externalNodeCmdStore)`. Add `TestNewBrevCommand_BYONCommandSurface` to prove `join` exists once and `register` resolves to the same pointer rather than a separately registered command. - -- [ ] **Step 6: Run focused tests and format** - -```bash -gofmt -w pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -go test ./pkg/cmd/register ./pkg/cmd -run 'Test(NewCmdJoin|RunJoin|LoadRegistration|NewBrevCommand_BYON)' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -git commit -m "feat: separate network join from SSH enablement" -``` - - ---- - -## Task 3: Require Joined, Connected Membership Before `enable-ssh` - -**Files:** - -- Create: `pkg/cmd/register/node.go` -- Create: `pkg/cmd/register/node_test.go` -- Modify: `pkg/cmd/enablessh/enablessh.go` -- Modify: `pkg/cmd/enablessh/enablessh_test.go` - -- [ ] **Step 1: Write shared-node lookup tests** - -Add `TestFetchRegisteredNode_Success`, `TestFetchRegisteredNode_RPCError`, and `TestFetchRegisteredNode_NilNodeIsError`. The helper contract is: - -```go -func FetchRegisteredNode( - ctx context.Context, - nodeClients externalnode.NodeClientFactory, - tokenProvider externalnode.TokenProvider, - reg *DeviceRegistration, -) (*nodev1.ExternalNode, error) -``` - -Assert the request contains both `ExternalNodeId` and `OrganizationId`, RPC errors retain `error retrieving joined node` context, and a response with no node returns a nonnil error. - -- [ ] **Step 2: Write full enable orchestration tests** - -Introduce fakes for platform, registration store, connector, provisioner, and store. Record operation order and add: - -- `TestNewCmdEnableSSH_RejectsPositionalArguments`. -- `TestRunEnableSSH_MissingRegistrationDirectsUserToJoin`: exact guidance, no auth, node lookup, tunnel, or provisioner call. -- `TestRunEnableSSH_MissingBackendNodeDoesNotConnectOrProvision`. -- `TestRunEnableSSH_ConnectedTunnelProvisionsSSH`: order is platform, registration, auth, node, tunnel, provision. -- `TestRunEnableSSH_ReconnectsBeforeProvisioning`: the fake connector changes disconnected to connected and provision runs after it. -- `TestRunEnableSSH_TunnelFailureDoesNotProvision`. -- `TestRunEnableSSH_UnconfirmedTunnelDoesNotProvision`. -- `TestRunEnableSSH_NeverAddsNode`: the fake node service fails the test if `AddNode` is called. - -The injected mutation boundary should be: - -```go -type sshAccessProvisioner interface { - Provision( - context.Context, - *terminal.Terminal, - externalnode.TokenProvider, - *register.DeviceRegistration, - *entity.User, - *nodev1.ExternalNode, - ) error -} -``` - -- [ ] **Step 3: Run the tests and observe failure** - -```bash -go test ./pkg/cmd/register ./pkg/cmd/enablessh -run 'Test(FetchRegisteredNode|NewCmdEnableSSH|RunEnableSSH)' -count=1 -``` - -Expected: FAIL because lookup is local to `enablessh` and SSH provisioning precedes a strict tunnel check. - -- [ ] **Step 4: Add the shared registered-node helper** - -Create `node.go` with the signature above. Build the existing `GetNodeRequest`, wrap RPC failure, and reject `resp == nil`, `resp.Msg == nil`, or `resp.Msg.GetExternalNode() == nil` with: - -```text -registered node was not returned by Brev; run "brev leave" and "brev join" to repair membership -``` - -Delete the private `fetchRegisteredNode` from `enablessh.go` and use the shared helper in both SSH commands added by this plan. - -- [ ] **Step 5: Refactor enablement behind a post-connect provisioner** - -Use these dependencies: - -```go -type enableSSHDeps struct { - platform externalnode.PlatformChecker - nodeClients externalnode.NodeClientFactory - registrationStore register.RegistrationStore - tunnel register.NetBirdConnector - provisioner sshAccessProvisioner -} - -type defaultSSHAccessProvisioner struct { - prompter terminal.Selector - nodeClients externalnode.NodeClientFactory -} -``` - -`defaultEnableSSHDeps` must use `register.LinuxPlatform{}`, `register.NewFileRegistrationStore()`, `register.Netbird{}`, and a `defaultSSHAccessProvisioner` built with the same real node-client factory and terminal selector. - -Move current Linux-user lookup, `checkSSHDaemon`, `ResolveSSHAccessPort`, and `SetupAndRegisterNodeSSHAccess` into `defaultSSHAccessProvisioner.Provision`. Keep the success output in `runEnableSSH` after the provisioner returns. - -Implement this exact mutation boundary in `runEnableSSH`: - -```go -exists, err := deps.registrationStore.Exists() -if err != nil { - return fmt.Errorf("check joined-device registration: %w", err) -} -if !exists { - return breverrors.New(`This machine has not joined a Brev network; run "brev join" first.`) -} - -reg, err := deps.registrationStore.Load() -if err != nil { - return fmt.Errorf("read joined-device registration: %w", err) -} -brevUser, err := s.GetCurrentUser() -if err != nil { - return breverrors.WrapAndTrace(err) -} -node, err := register.FetchRegisteredNode(ctx, deps.nodeClients, s, reg) -if err != nil { - return fmt.Errorf("enable SSH failed: %w", err) -} -if err := deps.tunnel.EnsureConnected(ctx); err != nil { - return fmt.Errorf("enable SSH requires a connected Brev tunnel: %w", err) -} -if err := deps.provisioner.Provision(ctx, t, s, reg, brevUser, node); err != nil { - return fmt.Errorf("enable SSH failed: %w", err) -} -``` - -Add `Args: cobra.NoArgs` and update help to say “joined node.” A healthy tunnel performs no privileged command; only `Netbird.EnsureConnected` invokes interactive `sudo` when it must start the service or run `netbird up`. Do not add an unconditional sudo gate, AddNode, organization, or registration-save behavior. - -- [ ] **Step 6: Run tests and format** - -```bash -gofmt -w pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go -go test ./pkg/cmd/register ./pkg/cmd/enablessh -run 'Test(FetchRegisteredNode|NewCmdEnableSSH|RunEnableSSH)' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go -git commit -m "feat: require joined tunnel before enabling SSH" -``` - - ---- - -## Task 4: Build a Privileged, Node-Wide Brev Key Cleanup Boundary - -**Files:** - -- Modify: `pkg/cmd/register/sshkeys.go` -- Modify: `pkg/cmd/register/sshkeys_test.go` -- Modify: `main.go` -- Create: `pkg/cmd/disablessh/localkeys.go` -- Create: `pkg/cmd/disablessh/localkeys_test.go` -- Create: `pkg/cmd/disablessh/localkeys_linux.go` -- Create: `pkg/cmd/disablessh/localkeys_linux_test.go` -- Create: `pkg/cmd/disablessh/localkeys_unsupported.go` -- Create: `pkg/cmd/disablessh/testdata/passwd.txt` -- Create: `pkg/cmd/disablessh/testdata/authorized_keys.before` -- Create: `pkg/cmd/disablessh/testdata/authorized_keys.after` - -The existing sudo gate only validates or refreshes credentials; it does not elevate the Go process. A node-wide sweep therefore needs a narrow privileged subprocess rather than calling `RemoveBrevAuthorizedKeys` for other users from the unprivileged CLI. - -- [ ] **Step 1: Expose and lock down the marker predicate** - -Rename the existing private helper without changing its semantics: - -```go -// IsBrevManagedAuthorizedKeysLine reports whether a line was managed by a -// current or legacy Brev CLI SSH flow. -func IsBrevManagedAuthorizedKeysLine(line string) bool { - return strings.Contains(line, BrevKeyPrefixLegacy) || strings.Contains(line, "#brev-portID:") -} -``` - -Update internal callers and add table-driven coverage for current marker, legacy marker, unrelated key, blank line, and a comment that contains neither exact marker. - -- [ ] **Step 2: Add pure account-parser and byte-filter tests** - -Create fixtures with root, two normal users, a service account, and two users sharing one home. `authorized_keys.before` must include an unrelated options-prefixed key, one current Brev marker, one legacy marker, a blank line, CRLF content, and a final newline. `authorized_keys.after` must contain every unrelated byte in the original order. - -Define the OS-neutral shapes: - -```go -const cleanupHelperArg = "__brev-disable-ssh-cleanup" - -type KeyCleanupResult struct { - AccountsScanned int `json:"accounts_scanned"` - AccountsChanged int `json:"accounts_changed"` - KeysRemoved int `json:"keys_removed"` -} - -type localAccount struct { - Username string - HomeDir string -} - -type localKeyCleaner interface { - RemoveBrevKeys(context.Context) (KeyCleanupResult, error) -} - -func parsePasswd(data []byte) ([]localAccount, error) -func stripBrevManagedAuthorizedKeyLines(data []byte) (cleaned []byte, removed int) -``` - -Add: - -- `TestParsePasswd_EnumeratesAndDeduplicatesHomes`: all account types remain; duplicate home appears once. -- `TestParsePasswd_RejectsMalformedRecord`: fewer than seven fields is an error with line context. -- `TestParsePasswd_RejectsRelativeHome`: only absolute home paths are accepted. -- `TestStripBrevManagedAuthorizedKeyLines_PreservesUnrelatedBytes`: compare exact bytes with `authorized_keys.after` and assert two removals. -- `TestStripBrevManagedAuthorizedKeyLines_NoMarkersReturnsOriginalBytes`: zero removals and byte equality. - -Implement filtering with `bytes.SplitAfter(data, []byte("\n"))`; remove the trailing `\n` and optional `\r` only for marker classification, and append every unremoved segment unchanged. This preserves blank lines, CRLF, ordering, and final-newline state. - -- [ ] **Step 3: Add aggregate-cleaner tests** - -Use injected account listing and per-account cleaning: - -```go -type systemLocalKeyCleaner struct { - listAccounts func(context.Context) ([]localAccount, error) - cleanAccount func(localAccount) (int, error) -} - -func (c systemLocalKeyCleaner) RemoveBrevKeys(context.Context) (KeyCleanupResult, error) -func newSystemLocalKeyCleaner() localKeyCleaner -func newPrivilegedLocalKeyCleaner() localKeyCleaner -``` - -Add `TestSystemLocalKeyCleaner_AttemptsEveryAccountAndJoinsErrors`. Return failures for the first and third account, verify the second still runs, verify the result counts successful removals, and assert the combined error contains both usernames and home paths. Use `breverrors.Join` after wrapping each account failure. - -- [ ] **Step 4: Run the pure tests and observe failure** - -```bash -go test ./pkg/cmd/register ./pkg/cmd/disablessh -run 'Test(IsBrevManaged|ParsePasswd|StripBrev|SystemLocalKeyCleaner)' -count=1 -``` - -Expected: FAIL because the package and exported predicate do not exist. - -- [ ] **Step 5: Implement the OS-neutral cleanup and sudo protocol** - -Add a privileged runner with injectable seams: - -```go -type privilegedCommandRunner interface { - Output(context.Context, string, ...string) ([]byte, error) -} - -type privilegedLocalKeyCleaner struct { - geteuid func() int - executable func() (string, error) - runner privilegedCommandRunner - direct localKeyCleaner -} - -func (c privilegedLocalKeyCleaner) RemoveBrevKeys(ctx context.Context) (KeyCleanupResult, error) { - if c.geteuid() == 0 { - return c.direct.RemoveBrevKeys(ctx) - } - executable, err := c.executable() - if err != nil { - return KeyCleanupResult{}, fmt.Errorf("locate Brev executable: %w", err) - } - output, err := c.runner.Output(ctx, "sudo", "-n", executable, cleanupHelperArg) - if err != nil { - return KeyCleanupResult{}, fmt.Errorf("run privileged Brev key cleanup: %w", err) - } - var result KeyCleanupResult - if err := json.Unmarshal(output, &result); err != nil { - return KeyCleanupResult{}, fmt.Errorf("decode privileged Brev key cleanup result: %w", err) - } - return result, nil -} -``` - -The real runner must use `exec.CommandContext(...).Output()` and include `*exec.ExitError.Stderr` in its returned error, but never mix stderr into the JSON stdout stream. - -Add this exported dispatcher for `main.go`: - -```go -func RunLocalKeyCleanupHelper(ctx context.Context, args []string, stdout io.Writer) (bool, error) -``` - -It returns `(false, nil)` unless the first argument exactly equals `cleanupHelperArg`. Once selected, it accepts exactly one argument, requires Linux, requires `os.Geteuid() == 0`, invokes `newSystemLocalKeyCleaner`, and JSON-encodes only `KeyCleanupResult` to stdout. It accepts no usernames, home directories, or file paths. - -Use an unexported injected variant in tests and add: - -- `TestPrivilegedLocalKeyCleaner_RootRunsDirectly`. -- `TestPrivilegedLocalKeyCleaner_UsesFixedSudoCommandWhenNotRoot`. -- `TestPrivilegedLocalKeyCleaner_RejectsInvalidJSON`. -- `TestRunLocalKeyCleanupHelper_IgnoresNormalCLIArguments`. -- `TestRunLocalKeyCleanupHelper_RejectsExtraArguments`. -- `TestRunLocalKeyCleanupHelper_RejectsNonRoot`. -- `TestRunLocalKeyCleanupHelper_EmitsJSON`. - -- [ ] **Step 6: Implement secure Linux account enumeration and file replacement** - -Put Linux implementation behind `//go:build linux`. Resolve `getent` only from fixed candidates `/usr/bin/getent` and `/bin/getent`, run `getent passwd`, and parse its stdout. A missing command, nonzero exit, or malformed record fails the sweep instead of falsely reporting completeness. - -For each deduplicated absolute home: - -1. Start from an open descriptor for `/` and walk every cleaned absolute-home component with `unix.Openat(..., unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW)`. Reject `..`, symlinks in any intermediate or final component, and non-directories; `O_NOFOLLOW` on one absolute-path open is insufficient because it protects only the final component. -2. Open literal `.ssh` from the verified home descriptor with the same directory/no-follow flags. -3. Use `unix.Fstatat` with `AT_SYMLINK_NOFOLLOW` on literal `authorized_keys` and require a regular file before opening it. Then open it with `unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK`, immediately `Fstat` the descriptor again to close the race, and reject a FIFO, device, directory, socket, or changed/non-regular target before reading. -4. Treat `ENOENT` for a home component, `.ssh`, or `authorized_keys` as zero removals. Return every other unsafe-type or path error with account and path context. -5. Read the file from its descriptor, filter it, and skip all writes when no markers match. -6. Record the original `Stat_t.Uid`, `Stat_t.Gid`, and permission bits. -7. Create a random `authorized_keys.brev-cleanup-*` file in the already-open `.ssh` directory with `O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW`. -8. Write all cleaned bytes, `Fchown` to the original UID/GID, then `Fchmod` to the original permission bits (chown can clear mode bits), `Fsync`, atomically `Renameat` over literal `authorized_keys`, and `Fsync` the directory. -9. Close descriptors on every path and unlink an unrenamed temporary file on failure. - -Do not recurse, follow symlinks, evaluate shell text, or accept a path from the caller. The non-Linux file must return `brev disable-ssh local cleanup is only supported on Linux` while preserving compilation on Darwin. - -- [ ] **Step 7: Add Linux filesystem tests** - -Behind `//go:build linux`, add: - -- `TestSystemAuthorizedKeysCleaner_RemovesBothMarkersAndPreservesModeAndOwnership`: use a mode with the setgid bit and verify the full promised mode after the required `Fchown`-then-`Fchmod` order. -- `TestSystemAuthorizedKeysCleaner_NoMarkersDoesNotRewrite`: compare inode before/after to prove no replacement. -- `TestSystemAuthorizedKeysCleaner_MissingSSHDirectoryIsSuccess`. -- `TestSystemAuthorizedKeysCleaner_MissingAuthorizedKeysIsSuccess`. -- `TestSystemAuthorizedKeysCleaner_RejectsSSHDirectorySymlink`. -- `TestSystemAuthorizedKeysCleaner_RejectsAuthorizedKeysSymlink`. -- `TestSystemAuthorizedKeysCleaner_RejectsIntermediateHomeSymlink`. -- `TestSystemAuthorizedKeysCleaner_RejectsFIFOWithoutBlocking`. -- `TestSystemAuthorizedKeysCleaner_RejectsNonRegularAuthorizedKeys`. - -Use `t.TempDir()` only; never point tests at a real account home. Ownership assertions may compare unchanged UID/GID without changing them. - -- [ ] **Step 8: Dispatch the helper before normal CLI initialization** - -At the top of `main`, before Sentry, analytics, stores, version checks, or Cobra setup: - -```go -handled, err := disablessh.RunLocalKeyCleanupHelper(context.Background(), os.Args[1:], os.Stdout) -if handled { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return -} -``` - -This prevents the root subprocess from logging in, creating analytics events, or running a second backend operation. - -- [ ] **Step 9: Run tests, format, and verify both build targets** - -```bash -gofmt -w main.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go -go test ./pkg/cmd/register ./pkg/cmd/disablessh -run 'Test(IsBrevManaged|ParsePasswd|StripBrev|SystemLocalKeyCleaner|PrivilegedLocalKeyCleaner|RunLocalKeyCleanupHelper)' -count=1 -go test . -run '^$' -GOOS=linux GOARCH=amd64 go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh -GOOS=linux GOARCH=amd64 go build -o /tmp/brev-cli-linux . -``` - -Expected: OS-neutral tests PASS locally, the Darwin root executable compiles with the early dispatcher, and both the Linux package tests and Linux root executable cross-compile. Run the tagged filesystem tests on a Linux runner during final verification. - -- [ ] **Step 10: Commit** - -```bash -git add main.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go pkg/cmd/disablessh/testdata -git commit -m "feat: add privileged node-wide Brev key cleanup" -``` - - ---- - -## Task 5: Add Backend-First, Node-Wide `disable-ssh` - -**Files:** - -- Create: `pkg/cmd/disablessh/disablessh.go` -- Create: `pkg/cmd/disablessh/disablessh_test.go` -- Modify: `pkg/cmd/cmd.go` -- Modify: `pkg/cmd/cmd_test.go` - -- [ ] **Step 1: Define orchestration seams and write command tests** - -Use these narrow command dependencies: - -```go -type DisableSSHStore interface { - GetCurrentUser() (*entity.User, error) - GetAccessToken() (string, error) -} - -type disableSSHDeps struct { - platform externalnode.PlatformChecker - confirmer terminal.Confirmer - gater sudo.Gater - tunnel register.NetBirdConnector - nodeClients externalnode.NodeClientFactory - registrationStore register.RegistrationStore - keyCleaner localKeyCleaner -} - -func NewCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore) *cobra.Command -func newCmdDisableSSH(t *terminal.Terminal, store DisableSSHStore, deps disableSSHDeps) *cobra.Command -func runDisableSSH( - ctx context.Context, - t *terminal.Terminal, - warnings io.Writer, - store DisableSSHStore, - deps disableSSHDeps, - skipConfirm bool, -) error -``` - -`defaultDisableSSHDeps` must use `register.LinuxPlatform{}`, `register.TerminalPrompter{}`, `sudo.Default`, `register.Netbird{}`, `register.DefaultNodeClientFactory{}`, `register.NewFileRegistrationStore()`, and `newPrivilegedLocalKeyCleaner()`. The public constructor must close over these defaults; tests call the injected constructor. - -Add `TestNewCmdDisableSSH_CommandSurface` and `TestNewCmdDisableSSH_RejectsArguments`. Assert `Use: "disable-ssh"`, `Args: cobra.NoArgs`, configuration annotation, and `--approve`. - -- [ ] **Step 2: Write state-machine tests before implementation** - -Use a fake ConnectRPC service that records GetNode, RevokeNodeSSHAccess, RemoveNode, ClosePort, and AddNode calls. Add: - -- `TestRunDisableSSH_MissingRegistrationDoesNotAuthenticateOrCallRPC`. -- `TestRunDisableSSH_CancelStopsBeforeSudoTunnelRevocationAndCleanup`. -- `TestRunDisableSSH_ApproveSkipsConfirmationButPrintsSafetyWarning`. -- `TestRunDisableSSH_ShowsGrantAndDistinctLinuxAccountCounts`. -- `TestRunDisableSSH_IgnoresNilAccessEntries`. -- `TestRunDisableSSH_ConnectsBeforeFirstRevocation`. -- `TestRunDisableSSH_RevokesEveryExactTupleSequentiallyOnce`. -- `TestRunDisableSSH_ContinuesAfterMiddleRevocationFailureAndJoinsErrors`. -- `TestRunDisableSSH_AnyRevocationFailureBlocksLocalCleanup`. -- `TestRunDisableSSH_NoGrantsSkipsTunnelAndStillCleansOrphanedKeys`. -- `TestRunDisableSSH_LocalCleanupFailureReturnsErrorAndPreservesMembership`. -- `TestRunDisableSSH_DoesNotRemoveNodeClosePortUninstallNetBirdOrDeleteRegistration`. - -Use access records with repeated Linux accounts so the warning asserts both total-grant and distinct-account counts. For exact tuple assertions, compare: - -```go -&nodev1.RevokeNodeSSHAccessRequest{ - ExternalNodeId: reg.ExternalNodeID, - PortId: access.GetPortId(), - UserId: access.GetUserId(), - LinuxUser: access.GetLinuxUser(), -} -``` - -- [ ] **Step 3: Run the new tests and observe failure** - -```bash -go test ./pkg/cmd/disablessh ./pkg/cmd -run 'Test(NewCmdDisableSSH|RunDisableSSH|NewBrevCommand_BYON)' -count=1 -``` - -Expected: FAIL because the public command does not exist. - -- [ ] **Step 4: Implement preflight and confirmation** - -Configure the command as: - -```go -Use: "disable-ssh", -Short: "Disable all Brev-managed SSH access on this node", -Args: cobra.NoArgs, -DisableFlagsInUseLine: true, -Annotations: map[string]string{"configuration": ""}, -``` - -Implement preflight in this order: - -1. Linux compatibility. -2. `registrationStore.Exists`; if false, return `This machine has not joined a Brev network; run "brev join" first.` -3. Load registration. -4. Authenticate with `GetCurrentUser`. -5. Fetch the registered node through `register.FetchRegisteredNode`. -6. Copy every non-nil entry from `node.GetSshAccess()` into a new slice before mutation; nil protobuf entries are not active grants. -7. Print node, total grants, distinct Linux accounts, and the warning that existing sessions are not forcibly terminated. -8. Confirm unless `--approve`; a cancellation returns nil. -9. Only after confirmation, call the sudo gate with reason `Node-wide Brev SSH cleanup`. - -The active-session and node-wide-scope warnings must use the supplied stderr writer even with `--approve`. - -- [ ] **Step 5: Implement backend-first revocation and conditional cleanup** - -If the access snapshot is nonempty, call `deps.tunnel.EnsureConnected(ctx)` before creating any revoke request. If it fails, return without local cleanup. When the snapshot is empty, skip the tunnel entirely. - -Revoke sequentially in slice order. Continue after errors and collect each with exact context: - -```go -revokeErrs = append(revokeErrs, fmt.Errorf( - "revoke SSH access for user %q, Linux account %q, port %q: %w", - access.GetUserId(), - access.GetLinuxUser(), - access.GetPortId(), - err, -)) -``` - -After the loop: - -```go -if err := breverrors.Join(revokeErrs...); err != nil { - return fmt.Errorf("disable SSH backend cleanup incomplete: %w", err) -} -result, err := deps.keyCleaner.RemoveBrevKeys(ctx) -if err != nil { - return fmt.Errorf("disable SSH local key cleanup incomplete: %w", err) -} -``` - -Do not suppress arbitrary RPC NotFound errors: the backend revoke operation itself is already idempotent for an absent exact tuple, while a transport NotFound can mean a missing node or port and must not authorize a broad local sweep. - -Print success only after the cleaner succeeds, including `result.KeysRemoved` and `result.AccountsChanged`. Keep membership and registration intact on every outcome. - -- [ ] **Step 6: Wire the command exactly once at the root** - -Import `pkg/cmd/disablessh` in `pkg/cmd/cmd.go` and add: - -```go -cmd.AddCommand(disablessh.NewCmdDisableSSH(t, externalNodeCmdStore)) -``` - -Extend `TestNewBrevCommand_BYONCommandSurface` to assert one canonical `disable-ssh` command and no alias. - -- [ ] **Step 7: Run tests and format** - -```bash -gofmt -w pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -go test ./pkg/cmd/disablessh ./pkg/cmd -run 'Test(NewCmdDisableSSH|RunDisableSSH|NewBrevCommand_BYON)' -count=1 -go test -race ./pkg/cmd/disablessh -count=1 -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -git commit -m "feat: add node-wide disable-ssh command" -``` - ---- - -## Task 6: Make `leave` Canonical and Membership-Only - -**Files:** - -- Modify: `pkg/cmd/deregister/deregister.go` -- Modify: `pkg/cmd/deregister/deregister_test.go` -- Modify: `pkg/cmd/cmd.go` -- Modify: `pkg/cmd/cmd_test.go` - -The current backend masks a missing `GetNode` as Connect `PermissionDenied`, while `RemoveNode` itself is idempotent for a missing node. Do not downgrade `PermissionDenied`: preserve the approved stop-on-lookup-error contract by performing leave preflight through organization-scoped `ListNodes` and matching the persisted external-node ID. An absent ID is a retryable missing node only when the response is complete; if the response has a next-page token, stop because the current backend ignores requested page parameters and the CLI cannot safely prove absence. - -- [ ] **Step 1: Write canonical command and alias tests** - -Add: - -- `TestNewCmdLeave_CommandSurface`: `Use: "leave"`, alias `deregister`, `Args: cobra.NoArgs`, configuration annotation, and `--approve`. -- `TestNewCmdLeave_DeregisterAliasWarnsOnExecution`: assert the approved two-line warning on Cobra stderr. -- `TestNewCmdLeave_HelpDoesNotWarn`. -- `TestNewCmdLeave_RejectsArguments` for both canonical and alias invocations. - -The execution-only alias warning is: - -```go -if cmd.CalledAs() == "deregister" { - fmt.Fprintln(cmd.ErrOrStderr(), `Warning: "brev deregister" is deprecated; use "brev leave" instead.`) - fmt.Fprintln(cmd.ErrOrStderr(), `This command no longer removes SSH keys; run "brev disable-ssh" before leaving if you want to remove Brev-managed SSH access.`) -} -``` - -- [ ] **Step 2: Write leave state-machine tests** - -Use a shared event recorder across registration, auth, node RPC, confirmation, sudo, NetBird, and registration deletion. Add: - -- `TestRunLeave_RemainingGrantsWarnButDoNotBlock`: known access records print the retained-host-key warning and cancellation guidance. -- `TestRunLeave_ApproveSkipsConfirmationButNotWarnings`. -- `TestRunLeave_CancelStopsBeforeSudoAndMutation`. -- `TestRunLeave_OrderIsRemoveNodeUninstallDeleteRegistration`. -- `TestRunLeave_RemoveNodeFailureStopsLocalTeardown`. -- `TestRunLeave_CompleteNodeListWithoutRegisteredIDAllowsAuthoritativeRemoveRetry`. -- `TestRunLeave_ListPermissionDeniedStopsBeforeConfirmationAndMutation`. -- `TestRunLeave_RegisteredIDAbsentFromIncompleteListStopsBeforeMutation`. -- `TestRunLeave_OtherLookupFailureStopsBeforeConfirmationAndMutation`. -- `TestRunLeave_RemoveNodeNotFoundIsAccepted`. -- `TestRunLeave_NetBirdFailureReturnsErrorAndRetainsRegistration`. -- `TestRunLeave_RegistrationDeleteFailureReturnsErrorAndNoSuccess`. -- `TestRunLeave_NeverRevokesSSHOrEditsAuthorizedKeys`. - -Assert warning text is written through the injected stderr writer, including with `--approve`. Assert no success string is present for every failure. - -- [ ] **Step 3: Run the new tests and observe failure** - -```bash -go test ./pkg/cmd/deregister ./pkg/cmd -run 'Test(NewCmdLeave|RunLeave|NewBrevCommand_BYON)' -count=1 -``` - -Expected: FAIL because `leave` does not exist and deregistration still edits the invoking user's key file. - -- [ ] **Step 4: Rename the public orchestration and remove SSH dependencies** - -Keep the package name, but use: - -```go -type LeaveStore interface { - GetCurrentUser() (*entity.User, error) - GetAccessToken() (string, error) -} - -type netBirdUninstaller interface { - Uninstall() error -} - -type leaveDeps struct { - platform externalnode.PlatformChecker - confirmer terminal.Confirmer - gater sudo.Gater - netbird netBirdUninstaller - nodeClients externalnode.NodeClientFactory - registrationStore register.RegistrationStore -} - -func NewCmdLeave(t *terminal.Terminal, store LeaveStore) *cobra.Command -func runLeave( - ctx context.Context, - t *terminal.Terminal, - warnings io.Writer, - store LeaveStore, - deps leaveDeps, - skipConfirm bool, -) error -``` - -`defaultLeaveDeps` must use `register.LinuxPlatform{}`, `register.TerminalPrompter{}`, `sudo.Default`, `register.Netbird{}`, `register.DefaultNodeClientFactory{}`, and `register.NewFileRegistrationStore()`. - -Delete `SSHKeyRemover`, `brevSSHKeyRemover`, `os/user`, and all direct key-removal output. Use canonical Cobra metadata and retain only `--approve`. - -- [ ] **Step 5: Implement read-only preflight and warnings** - -After Linux check, registration load, and authentication, call a private organization-scoped helper: - -```go -func lookupJoinedNodeForLeave( - ctx context.Context, - client nodev1connect.ExternalNodeServiceClient, - reg *register.DeviceRegistration, -) (node *nodev1.ExternalNode, missing bool, err error) { - resp, err := client.ListNodes(ctx, connect.NewRequest(&nodev1.ListNodesRequest{ - OrganizationId: reg.OrgID, - })) - if err != nil { - return nil, false, fmt.Errorf("list organization nodes: %w", err) - } - if resp == nil || resp.Msg == nil { - return nil, false, fmt.Errorf("list organization nodes: empty response") - } - for _, candidate := range resp.Msg.GetItems() { - if candidate != nil && candidate.GetExternalNodeId() == reg.ExternalNodeID { - return candidate, false, nil - } - } - if resp.Msg.GetNextPageToken() != "" { - return nil, false, fmt.Errorf("registered node was not in the returned page and node listing is incomplete") - } - return nil, true, nil -} -``` - -Any ListNodes error, including `PermissionDenied`, or an incomplete response without the registered ID returns `inspect joined node before leaving` before confirmation, sudo, or mutation. When the complete list proves the node is absent, continue with no access snapshot and write that the backend node is already absent but tagged host keys may remain. This is the idempotent retry path; authoritative `RemoveNode` is still called defensively. - -Always write this safety warning before confirmation: - -```text -Leaving removes the Brev tunnel and may interrupt commands using Brev SSH. Run this locally or through out-of-band access. -``` - -When known non-nil grants remain, include total grant and distinct Linux-account counts and this action: - -```text -Leaving stops Brev-routed SSH but does not remove keys from authorized_keys. Cancel and run "brev disable-ssh" first if you want Brev-managed SSH credentials removed. -``` - -Do not block on grants. Confirm through `terminal.Confirmer` unless `--approve` was supplied, then call the sudo gate only after confirmation so cancellation has no elevation side effect. - -- [ ] **Step 6: Implement authoritative, retry-safe teardown** - -Call operations strictly in this order: - -```go -_, err := client.RemoveNode(ctx, connect.NewRequest(&nodev1.RemoveNodeRequest{ - ExternalNodeId: reg.ExternalNodeID, -})) -if err != nil && connect.CodeOf(err) != connect.CodeNotFound { - return fmt.Errorf("leave Brev network: remove node: %w", err) -} -if err := deps.netbird.Uninstall(); err != nil { - return fmt.Errorf("leave Brev network: uninstall tunnel: %w", err) -} -if err := deps.registrationStore.Delete(); err != nil { - return fmt.Errorf("leave Brev network: delete local registration: %w", err) -} -``` - -Do not delete registration when RemoveNode or Uninstall fails. Return the Delete error rather than printing a warning and false completion. Only after all three operations succeed, print `Left the Brev network.` - -- [ ] **Step 7: Update root wiring and command-surface tests** - -Replace `deregister.NewCmdDeregister` with `deregister.NewCmdLeave`. Extend the root test to prove `leave` exists once and `deregister` resolves to the exact same command pointer, not an independently registered command. - -- [ ] **Step 8: Run tests and format** - -```bash -gofmt -w pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -go test ./pkg/cmd/deregister ./pkg/cmd -run 'Test(NewCmdLeave|RunLeave|NewBrevCommand_BYON)' -count=1 -``` - -Expected: PASS. - -- [ ] **Step 9: Commit** - -```bash -git add pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -git commit -m "feat: separate network leave from SSH cleanup" -``` - ---- - -## Task 7: Document the Explicit Workflows and Verify the Complete Change - -**Files:** - -- Create: `docs/BYON.md` -- Modify: `README.md` -- Modify: `CHANGELOG.md` -- Modify: `.agents/skills/brev-cli/SKILL.md` -- Modify: `.agents/skills/brev-cli/reference/commands.md` -- Modify: all Go files touched in Tasks 1–6 if verification exposes formatting or lint defects - -- [ ] **Step 1: Write the BYON guide** - -Create `docs/BYON.md` with these explicit workflows: - -```text -Join networking only: - brev join - -Optionally enable SSH for yourself, then grant collaborators individually: - brev enable-ssh - brev grant-ssh - -Remove all Brev-managed SSH credentials, then retire membership: - brev disable-ssh - brev leave -``` - -Document that: - -- `register` and `deregister` are deprecated aliases. -- `enable-ssh` requires prior join and reconnects an existing disconnected tunnel. -- `disable-ssh` is node-wide, leaves ports allocated, does not stop sshd, and does not terminate active SSH sessions. -- `leave` removes the VPN route/backend node but does not remove physical host keys. -- `leave` currently preserves the old behavior of uninstalling NetBird even if the user installed it before Brev; tracking install ownership is a follow-up. - -Link this guide from `README.md` directly below the existing NVIDIA/Brev documentation link. - -- [ ] **Step 2: Update shipped CLI-skill guidance** - -Add a “BYON Network and SSH Commands” section to `.agents/skills/brev-cli/reference/commands.md` before configuration commands. Document `join`, `register`, `enable-ssh`, `grant-ssh`, `revoke-ssh`, `disable-ssh`, `leave`, and `deregister`, including the two canonical multi-command workflows. - -Update `.agents/skills/brev-cli/SKILL.md` so no instruction implies join automatically enables SSH. Keep cloud-instance commands outside this change untouched. - -- [ ] **Step 3: Add release notes** - -Under `CHANGELOG.md` Unreleased, record: - -- Added: `join`, `leave`, and node-wide `disable-ssh`. -- Changed: `join` no longer enables SSH; `enable-ssh` requires and reconnects existing membership. -- Deprecated: `register` and `deregister` remain aliases and warn on stderr. -- Migration: scripts using `--ssh-port` must run `brev join` followed by `brev enable-ssh`. - -- [ ] **Step 4: Search for stale user-facing terminology** - -Run: - -```bash -rg -n "brev (register|deregister)|--ssh-port|Registering your device|Deregistering your device" README.md CHANGELOG.md docs .agents/skills pkg/cmd -``` - -Expected: remaining `register`/`deregister` references are alias documentation, deprecation tests, internal persistence/backend terminology, or deliberate compatibility errors. No public example presents either alias as canonical, and no join help presents `--ssh-port` as supported. - -- [ ] **Step 5: Verify focused behavior** - -Run: - -```bash -go test ./pkg/cmd/register ./pkg/cmd/enablessh ./pkg/cmd/disablessh ./pkg/cmd/deregister ./pkg/cmd -count=1 -go test -race ./pkg/cmd/disablessh -count=1 -``` - -Expected: PASS. If the restricted sandbox denies an existing `httptest` listener, rerun outside the sandbox and record that environment distinction. - -- [ ] **Step 6: Format and lint the touched command packages** - -Run: - -```bash -gofmt -w main.go pkg/cmd/register/providers.go pkg/cmd/register/providers_test.go pkg/cmd/register/register.go pkg/cmd/register/register_test.go pkg/cmd/register/device_registration_store.go pkg/cmd/register/device_registration_store_test.go pkg/cmd/register/node.go pkg/cmd/register/node_test.go pkg/cmd/register/sshkeys.go pkg/cmd/register/sshkeys_test.go pkg/cmd/enablessh/enablessh.go pkg/cmd/enablessh/enablessh_test.go pkg/cmd/disablessh/disablessh.go pkg/cmd/disablessh/disablessh_test.go pkg/cmd/disablessh/localkeys.go pkg/cmd/disablessh/localkeys_test.go pkg/cmd/disablessh/localkeys_linux.go pkg/cmd/disablessh/localkeys_linux_test.go pkg/cmd/disablessh/localkeys_unsupported.go pkg/cmd/deregister/deregister.go pkg/cmd/deregister/deregister_test.go pkg/cmd/cmd.go pkg/cmd/cmd_test.go -golangci-lint run ./pkg/cmd/... ./pkg/sudo/... -``` - -Expected: PASS. Fix only defects caused by this branch; do not absorb unrelated lint churn. - -- [ ] **Step 7: Verify cross-platform compilation and Linux-only tests** - -On the current Darwin host: - -```bash -GOOS=linux GOARCH=amd64 go test -c -o /tmp/brev-disablessh-linux.test ./pkg/cmd/disablessh -GOOS=linux GOARCH=amd64 go build -o /tmp/brev-cli-linux . -``` - -On a Linux runner or Linux development host: - -```bash -go test -race ./pkg/cmd/disablessh -run 'TestSystemAuthorizedKeysCleaner' -count=1 -``` - -Expected: Linux package and root-command cross-builds PASS and descriptor/symlink tests PASS on Linux. - -- [ ] **Step 8: Inspect the rendered command surface** - -Run: - -```bash -go run . --help -go run . join --help -go run . leave --help -go run . enable-ssh --help -go run . disable-ssh --help -``` - -Expected: root help shows canonical `join`, `leave`, `enable-ssh`, and `disable-ssh`; aliases are not independent top-level entries; canonical help emits no warning; `--ssh-port` is hidden. - -- [ ] **Step 9: Attempt repository-wide verification and classify baseline failures** - -Run: - -```bash -go test ./pkg/... -count=1 -go test ./... -count=1 -``` - -Expected: all affected command packages pass. If the known macOS-only baseline failures recur in Linux e2e setup, JetBrains Gateway detection, or WSL store tests, capture their exact package/test names and confirm no affected BYON package failed. - -- [ ] **Step 10: Review scope and diff** - -Run: - -```bash -git status --short -git diff --check -git diff --stat 2954f39e..HEAD -git log --oneline 2954f39e..HEAD -``` - -Confirm there is no backend/proto change, no port closure, no sshd stop, no implicit AddNode from SSH commands, no SSH cleanup from leave, no node removal from disable, and no unrelated user work. - -- [ ] **Step 11: Commit documentation and final verification fixes** - -```bash -git add README.md CHANGELOG.md docs/BYON.md .agents/skills/brev-cli/SKILL.md .agents/skills/brev-cli/reference/commands.md -git commit -m "docs: explain explicit BYON network and SSH flows" -``` - -If verification required a source/test correction after Task 6, include only that tightly related correction in this commit and describe it in the commit body. - ---- diff --git a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md b/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md deleted file mode 100644 index 1c902c83..00000000 --- a/docs/superpowers/specs/2026-08-07-byon-network-ssh-separation-design.md +++ /dev/null @@ -1,455 +0,0 @@ -# BYON Network and SSH Command Separation Design - -## Summary - -Separate BYON network membership from Brev-managed SSH access through four -explicit command boundaries: - -| Capability | Canonical command | Compatibility alias | -| --- | --- | --- | -| Join the organization's Brev network | `brev join` | `brev register` | -| Leave the organization's Brev network | `brev leave` | `brev deregister` | -| Enable Brev-managed SSH for the current Brev/Linux user | `brev enable-ssh` | None | -| Revoke all backend-tracked Brev SSH grants on the node | `brev disable-ssh` | None | - -`join` and `leave` own only durable Brev/NetBird membership. `enable-ssh` and -`disable-ssh` own Brev-managed SSH authorization records. `grant-ssh` and -`revoke-ssh` remain the commands for individual collaborator grants. - -`register` and `deregister` remain deprecated Cobra aliases with no scheduled -removal release. Their handlers and behavior are the same as their canonical -commands, including the new separation from SSH. - -## Goals - -- Make joining an organization's Brev network the sole purpose of the onboarding - command. -- Use the durable membership pair `join` and `leave` rather than registration - terminology. -- Require an established and connected Brev tunnel before SSH can be enabled. -- Make node-wide SSH disablement an explicit operation independent of network - membership. -- Preserve individual collaborator management through `grant-ssh` and - `revoke-ssh`. -- Preserve compatible automation through deprecated `register` and - `deregister` aliases, with actionable migration output. -- Make partial backend revocation failures visible and safely retryable. -- Let `disable-ssh` make best-effort progress across every tracked grant while - revoking the invoking Brev user's own access last. - -## Non-goals - -- Stopping or reconfiguring the host's SSH daemon. -- Closing Brev port allocations from `disable-ssh`. Existing ports may have been - reused for purposes other than SSH, and the current model does not record - ownership. -- Adding port-ownership metadata, changing backend RPC or proto shapes, or - introducing a new backend bulk-revocation RPC. -- Changing the authorization semantics of `grant-ssh` or `revoke-ssh`. -- Renaming the internal `pkg/cmd/register` package, the persisted - `DeviceRegistration` model, registration file, or backend node terminology. -- Changing organization or default-network selection. -- Tracking whether Brev installed NetBird. `leave` preserves today's NetBird - uninstall behavior; protecting a pre-existing user-managed NetBird - installation is a separate follow-up. -- Editing host `authorized_keys` files from `disable-ssh`, including removing - Brev-tagged, orphaned, or otherwise static local keys. -- Forcibly terminating already-established SSH sessions. Revoking backend - authorization records does not kill active sessions. -- Adding a sudo gate, privileged helper, or same-binary re-execution path for - `disable-ssh`. - -## Naming Rationale - -The names follow established networking conventions: - -- ZeroTier uses `join` and `leave` for durable network membership. -- Tailscale uses `down` for a reversible disconnect and separates SSH enablement - from tailnet membership. -- NetBird also treats `down` as a temporary idle state and manages peer - membership separately. - -For Brev, `leave` therefore means authoritative membership removal rather than a -temporary tunnel stop. `logout` is avoided because it conventionally describes -human or device authentication state. `peer` is avoided as the command verb -because it describes the resulting network object rather than the user action. - -References: - -- [ZeroTier CLI](https://docs.zerotier.com/cli/) -- [Tailscale CLI](https://tailscale.com/docs/reference/tailscale-cli) -- [Tailscale SSH](https://tailscale.com/docs/features/tailscale-ssh) -- [NetBird CLI](https://docs.netbird.io/get-started/cli) -- [NetBird SSH](https://docs.netbird.io/manage/peers/ssh) - -## Command Model - -The intended user workflow is: - -```text -brev join -brev enable-ssh -brev grant-ssh -``` - -Explicit tracked-grant revocation and membership retirement are intentionally -two operations: - -```text -brev disable-ssh -brev leave -``` - -Running `leave` without `disable-ssh` is allowed. Brev-routed SSH stops because -the node leaves the network. Neither operation removes local keys from -`authorized_keys`; any such keys may still work through another network path. - -### Join and Register Alias - -The existing command constructor becomes `NewCmdJoin` with canonical Cobra -metadata: - -```go -Use: "join", -Aliases: []string{"register"}, -Args: cobra.NoArgs, -``` - -Public flags remain: - -- `--name`, `-n`: device name; required with `--org` in non-interactive mode. -- `--org`, `-o`: organization name; required with `--name` in non-interactive - mode. -- `--approve`: skip the confirmation prompt. - -The legacy `--ssh-port`, `-p` flag is removed from the public contract. A hidden -compatibility flag recognizes both forms and returns this error before sudo, -authentication, installation, RPC, or persistence side effects: - -```text ---ssh-port is no longer supported by brev join or brev register; run brev join, -then run brev enable-ssh on the joined machine -``` - -When `cmd.CalledAs()` reports `register`, the command writes this warning to -stderr and continues through the join handler: - -```text -Warning: "brev register" is deprecated; use "brev join" instead. -This command no longer enables SSH; run "brev enable-ssh" separately. -``` - -The warning is emitted for executions, not help rendering. Stdout remains -available for normal command output and scripts. - -### Join Flow - -`runJoin` retains only network-membership orchestration: - -1. Verify Linux compatibility and obtain sudo authorization. -2. Resolve the authenticated Brev user. -3. If local registration already exists, reconcile the backend node and local - NetBird connection without changing SSH state. -4. Resolve device name and organization from prompts or flags. -5. Confirm that the operation installs the Brev tunnel, collects a hardware - profile, creates the external node, persists identity, and joins the - organization's Brev network. -6. Install NetBird, collect hardware, call `AddNode`, save - `DeviceRegistration`, and run the backend-provided `netbird up` command. -7. Report network membership success and the optional next step: - -```text -SSH access was not enabled. To enable it for your user, run: brev enable-ssh -``` - -The flow never prompts for an SSH port, looks up a Linux account for a grant, -opens a port, installs an authorized key, or calls an SSH-access RPC. - -### Enable SSH - -`brev enable-ssh` remains the self-enablement command for the current Brev user -and current Linux account. It has a hard network-membership precondition: - -1. Verify Linux compatibility. -2. Load local `DeviceRegistration`. If none exists, fail with targeted guidance: - - ```text - This machine has not joined a Brev network; run "brev join" first. - ``` - -3. Authenticate and verify that the registered backend node still exists. -4. Ensure the existing NetBird service is running and connected. A temporarily - disconnected tunnel is started or reconnected automatically. -5. Wait for bounded, positive connectivity confirmation. A status error or - unconfirmed connection is a failure rather than assumed success. -6. Only then select or open a port, install the current user's tagged key, and - create the current user's SSH access record. - -`enable-ssh` may reconnect existing membership, but it never calls `AddNode`, -selects an organization, writes a new registration, or otherwise performs an -implicit join. A failed membership, node, or tunnel check occurs before any SSH -port, key, or access-record mutation. - -### Grant and Revoke SSH - -`brev grant-ssh` and `brev revoke-ssh` retain their existing roles: - -- `grant-ssh` creates one exact collaborator access tuple for a node, port, and - Linux account. -- `revoke-ssh` removes one exact access tuple. - -They do not become aliases or modes of `enable-ssh` or `disable-ssh`. - -### Disable SSH - -Add a canonical top-level command: - -```go -Use: "disable-ssh", -Args: cobra.NoArgs, -``` - -It accepts `--approve` to skip confirmation. It operates only on the locally -registered node and means "revoke every backend-tracked Brev SSH grant on this -node." It does not mean "stop sshd" or "clean authorized_keys." - -The flow is: - -1. Load local registration; if absent, direct the user to `brev join`. -2. Authenticate the invoking Brev user, fetch the registered backend node, and - take a fresh snapshot of every remaining `SSHAccess` tuple. -3. If the snapshot is empty, report that there are no grants to revoke and - return successfully without prompting. -4. Show a node-wide confirmation with the active grant count. State that active - sessions are not forcibly terminated. `--approve` skips this prompt but not - the active-session warning. -5. Stable-partition the snapshot so grants for collaborators remain first and - every grant belonging to the invoking Brev user is last. Preserve snapshot - order within each group. -6. Call `RevokeNodeSSHAccess` sequentially for every tuple. Continue after - individual failures, including through the invoking user's final record, and - aggregate contextual errors with user, Linux account, and port details. -7. Return nonzero if authentication, lookup, or any revocation is incomplete. - Report overall success only after every record in the fresh snapshot has - been revoked. - -A no-access state is successful, making the command safely repeatable. Each -retry fetches a fresh backend access snapshot, skips records already removed, -and attempts only work that remains. Membership and registration remain intact -after every outcome so revocation can be retried. - -`disable-ssh` does not remove the backend node, stop or uninstall NetBird, delete -registration, stop sshd, close ports, terminate active sessions, or inspect or -modify `authorized_keys`. Ports remain because the current API cannot -distinguish ports created for SSH from pre-existing ports selected by the SSH -flow. - -### Leave and Deregister Alias - -The existing deregistration constructor becomes `NewCmdLeave`: - -```go -Use: "leave", -Aliases: []string{"deregister"}, -Args: cobra.NoArgs, -``` - -It retains `--approve`. When invoked as `deregister`, it writes this warning to -stderr: - -```text -Warning: "brev deregister" is deprecated; use "brev leave" instead. -This command does not revoke SSH access grants; run "brev disable-ssh" before -leaving if you want to revoke them. -``` - -The leave flow owns only membership teardown: - -1. Verify Linux compatibility, load local registration, and authenticate. -2. Fetch the registered node when it still exists and inspect its SSH access - records. Backend not-found is the idempotent retry case; any other lookup - error stops before mutation. -3. Always warn that removing the Brev tunnel may interrupt a command running - through Brev SSH. Recommend running locally or through out-of-band access. -4. If SSH access records remain, explain that Brev-routed SSH will stop and that - `leave` will not run the per-grant revocation flow. Tell the user to cancel - and run `brev disable-ssh` first if explicit best-effort revocation is - desired. -5. Confirm unless `--approve` was supplied. Warnings still print with - `--approve`. -6. Obtain sudo authorization before network removal so local teardown will not - require a new password prompt after connectivity is lost. -7. Call `RemoveNode`. Backend removal authoritatively removes network membership, - deallocates node ports, and deletes SSH access metadata, but it does not - remove physical host keys. -8. Uninstall NetBird using the existing Brev tunnel teardown behavior. -9. Delete local registration last. -10. Report completion only after membership and local teardown succeed. - -`leave` never calls `RevokeNodeSSHAccess` and never edits -`authorized_keys`. This preserves the command boundary even when grants exist. - -An already-removed backend node is treated as success during a retry. If backend -removal succeeds but NetBird uninstall or registration deletion fails, the -registration is retained when possible so `leave` can resume. Local failures -return nonzero rather than producing a false successful completion. - -## Code Boundaries - -- `pkg/cmd/cmd.go` registers `register.NewCmdJoin(...)`, - `deregister.NewCmdLeave(...)`, and the new - `disablessh.NewCmdDisableSSH(...)` exactly once. Cobra resolves aliases. -- `pkg/cmd/register` remains the home of the durable registration model and - shared external-node helpers. User-facing orchestration names change to - `joinOpts`, `runJoin`, and `runJoinSteps`. -- The SSH tail currently appended to registration is removed. Existing SSH - helpers remain available to SSH commands. -- `pkg/cmd/deregister` retains its internal package name but owns only leave - orchestration. Its direct authorized-key removal dependency is removed. -- `pkg/cmd/disablessh` is a focused new package with injected dependencies for - registration, current-user lookup, node lookup, confirmation, and grant - revocation. -- `disable-ssh` has no local key-cleanup abstraction, sudo gate, hidden helper - argument, same-binary privileged re-execution, or special dispatch in - `main.go`. The shared `pkg/sudo` behavior remains for commands that still - require it. -- Tunnel management gains a strict connected operation suitable for SSH - preconditions. It can start the service and run `netbird up` for existing - membership, but returns an error unless connectivity is positively confirmed. -- `enable-ssh` always uses that strict tunnel operation. `disable-ssh`, like - `revoke-ssh`, calls the public revocation API without managing the local - tunnel. -- User-facing guidance throughout the CLI changes from `brev register` to - `brev join`. Internal and backend registration terminology remains where it - describes persisted state or `AddNode`. - -## Compatibility - -- `brev register`, including its `--name`, `--org`, and `--approve` flags, - continues through the join flow. -- `brev deregister --approve` continues through the leave flow. -- Both aliases warn on stderr with their canonical replacement. -- The aliases do not retain legacy SSH side effects. -- All forms of the old SSH flag, `register --ssh-port`, `register -p`, - `join --ssh-port`, and `join -p`, fail before side effects with migration - guidance. -- `deregister` retains the same membership-only behavior as `leave`. Its warning - tells callers to run `disable-ssh` first when they want explicit per-grant - backend revocation. -- Removing either alias requires a future explicit compatibility decision. - -## Error Handling and Recovery - -- Membership validation and strict tunnel connectivity precede every - `enable-ssh` mutation. -- `disable-ssh` attempts every backend revocation in a fresh snapshot, reports - each failed tuple with user, Linux account, and port context, and joins the - failures into one final error. -- Collaborator records are attempted first and the invoking Brev user's own - records are attempted last, even after earlier failures. -- A partial revocation returns nonzero. A retry fetches a fresh snapshot and - attempts only records that remain. -- `leave` deletes registration last and treats backend not-found as an - idempotent retry condition. -- Neither teardown command prints success after an incomplete operation. -- Errors wrap the failed operation while preserving the underlying error for - callers and tests. -- Deprecation and safety warnings use stderr; ordinary status and success output - use the terminal's normal output path. - -## Testing - -### Command Surface - -Tests verify: - -- Cobra exposes `join` and `leave` as canonical commands. -- `register` and `deregister` resolve as aliases to the same handlers. -- Canonical invocations do not warn; aliases warn on stderr. -- All four affected commands reject positional arguments. -- Help and examples use canonical names. - -### Join - -Tests verify: - -- Interactive join prompts only for device name, organization, and membership - confirmation. -- Non-interactive join still requires both `--name` and `--org`. -- Legacy SSH flags fail before membership or SSH dependencies are called. -- Successful join installs and connects NetBird, creates and persists the node, - and makes no port or SSH-access call. -- Existing joined-node reconciliation remains intact. - -### Enable SSH - -Tests verify: - -- Missing registration returns targeted `brev join` guidance. -- A missing backend node fails without joining or mutating SSH. -- A connected tunnel proceeds normally. -- A disconnected existing tunnel reconnects and then proceeds. -- Failed or unconfirmed reconnection causes no port, key, or SSH-access side - effect. -- The command never calls `AddNode`. - -### Disable SSH - -Tests verify: - -- Confirmation describes node-wide scope and can be bypassed with `--approve`. -- `--approve` skips confirmation without suppressing the active-session warning. -- No disable flow invokes a key-cleanup dependency, sudo gate, subprocess - runner, hidden helper mode, or other automatic elevation path. -- Every active access tuple is revoked exactly once. -- Collaborator tuples preserve their snapshot order and are attempted before - every tuple owned by the invoking Brev user. -- All tuples are attempted even when one fails, and errors are aggregated. -- Aggregated failures retain their underlying causes, include `failed to revoke - one or more SSH access grants`, and do not print overall success. -- A no-access run succeeds without prompting or making revocation calls. -- A retry does not re-revoke records absent from the fresh backend snapshot. -- No `authorized_keys` file is inspected or modified. -- No node removal, NetBird teardown, registration deletion, sshd operation, or - port close occurs, and active sessions are not forcibly terminated. - -### Leave - -Tests verify: - -- Remaining grants produce the tracked-access warning but do not block leave. -- `--approve` skips confirmation but not warnings. -- No SSH revoke or authorized-key dependency is called. -- Ordering is backend removal, NetBird uninstall, then registration deletion. -- Backend removal failure stops local teardown. -- Backend not-found is accepted during retry. -- Other backend lookup failures stop before confirmation or mutation. -- NetBird or registration cleanup failure returns nonzero and preserves - recoverable state where possible. -- Success is printed only after complete membership teardown. - -### Verification Commands - -Implementation verification will include `gofmt` on touched Go files, focused -command-package tests, and `golangci-lint` when configured and practical. The -repository-wide suite will also be attempted. - -The current macOS baseline has unrelated failures in Linux e2e setup, JetBrains -Gateway detection, and WSL-specific store tests. Those failures will be reported -separately and will not be attributed to this change. - -## Documentation and Release Notes - -- CLI help and examples use `join` and `leave` as the primary verbs. -- Onboarding documents show `enable-ssh` as an explicit post-join choice. -- Offboarding documents show `disable-ssh` followed by `leave` for explicit - tracked-grant revocation followed by membership removal. -- `disable-ssh` documentation explains best-effort backend revocation, its - nonzero partial-failure result, current-user-last ordering, and that local - `authorized_keys` files are outside its scope. -- Documentation states that `leave` alone makes the node unreachable over the - Brev network but does not remove host keys. -- Release notes call out both deprecated aliases and the SSH behavior change. -- The existing risk that `leave` may uninstall a pre-existing user-managed - NetBird installation is documented as a follow-up, not silently changed in - this implementation. diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go index 367b9906..c9289a4c 100644 --- a/pkg/cmd/cmd_test.go +++ b/pkg/cmd/cmd_test.go @@ -31,41 +31,6 @@ func newTestFileStore(t *testing.T) *store.FileStore { ) } -func TestNewBrevCommand_BYONCommandSurface(t *testing.T) { - root := NewBrevCommand() - join, _, err := root.Find([]string{"join"}) - require.NoError(t, err) - register, _, err := root.Find([]string{"register"}) - require.NoError(t, err) - require.Equal(t, "join", join.Name()) - require.Same(t, join, register) - - disableSSH, _, err := root.Find([]string{"disable-ssh"}) - require.NoError(t, err) - require.Equal(t, "disable-ssh", disableSSH.Name()) - require.Empty(t, disableSSH.Aliases) - leave, _, err := root.Find([]string{"leave"}) - require.NoError(t, err) - deregister, _, err := root.Find([]string{"deregister"}) - require.NoError(t, err) - require.Equal(t, "leave", leave.Name()) - require.Equal(t, []string{"deregister"}, leave.Aliases) - require.Same(t, leave, deregister) - - var disableSSHCount int - var leaveCount int - for _, command := range root.Commands() { - if command.Name() == "disable-ssh" { - disableSSHCount++ - } - if command.Name() == "leave" { - leaveCount++ - } - } - require.Equal(t, 1, disableSSHCount) - require.Equal(t, 1, leaveCount) -} - func TestEmailCachingAuthStore_SaveCachesEmail(t *testing.T) { fs := newTestFileStore(t) s := &emailCachingAuthStore{