Skip to content

Conversation

@lixmal
Copy link
Collaborator

@lixmal lixmal commented Jun 19, 2025

Describe your changes

NetBird SSH Client

  • Port forwarding
  • Windows support
  • Non-interactive commands
  • Single command execution over SSH

SSH Server

  • SFTP
  • Port forwarding (without user switching)
  • PTY (interactive, non-interactive)
  • Non-PTY (commands)
  • Windows support
  • JWT auth (user identity instead of machine identity, can be turned off)

New Flags

# server
--enable-ssh-local-port-forwarding
--enable-ssh-remote-port-forwarding
--enable-ssh-root
--enable-ssh-sftp
--disable-ssh-auth

# client
--ssh-jwt-cache-ttl

UI

  • Better organization
image image image

Changes

  • Default port changed to 22022
  • Redirect port 22 to 22022 when SSH server enabled
  • Remove implicit OpenSSH firewall port
  • Management now passes its jwks config to peers with enabled ssh server
  • SSH server peers verify incoming clients' jwt with max token age
  • SSH clients request jwts from the IdP and send these for authentication with remote ssh peers
  • Add netbird ssh detect command to detect if the remote peer is running the NetBird server
  • Add netbird ssh proxy command for native ssh and sftp clients. The proxy requests jwts just like netbird ssh, passes them for authentication and bridges the connection between the native client and the remote server.
  • Add jwt capability to the wasm ssh client

Auth Flows

  1. NetBird SSH Flow (netbird ssh <peer>)
flowchart TD
    A[User: netbird ssh &lt;peer&gt;] --> B[Connect to peer:22]
    B --> C[Detect Server Type]
    C --> D[Send 'netbird-detect' request]

    D --> E{Server Response}

    E -->|No NetBird identifier| F[Regular SSH Server]
    E -->|NetBird + JWT required| G[NetBird with JWT Auth]
    E -->|NetBird + No JWT| H[NetBird without JWT]

    F --> I[Standard SSH Connection]
    H --> I

    G --> J[Request JWT from NetBird daemon]

    J --> J1{Check JWT cache}
    J1 -->|Cache valid| J2[Use cached JWT token]
    J1 -->|No cache/expired| J3[OIDC flow:<br/>User authorizes via IDP callback]
    J3 --> J4[Receive & cache JWT token]
    J4 --> K[Connect to peer SSH server]
    J2 --> K

    K --> M[Send JWT authentication request]
    M --> N{JWT Valid?}

    N -->|No| O[Connection Rejected]
    N -->|Yes| P[SSH Session Established]
    I --> P

    P --> Q[Interactive Shell / Execute Command / Port Forwarding]

    style G fill:#f57c00,color:#fff
    style J fill:#1976d2,color:#fff
    style J1 fill:#1976d2,color:#fff
    style J3 fill:#e65100,color:#fff
    style P fill:#388e3c,color:#fff
Loading
  1. Native SSH Flow (e.g. openssh client)
flowchart TD
    A[User: ssh &lt;peer&gt;] --> B[OpenSSH loads config:<br/>/etc/ssh/ssh_config.d/99-netbird.conf]
    B --> D{Host matches NetBird pattern?}

    D -->|No| E[Standard SSH connection]

    D -->|Yes| F[Run detection check:<br/>netbird ssh detect &lt;peer&gt; 22]
    F --> G{Is NetBird SSH server?}

    G -->|No| E

    G -->|Yes - JWT required| H[Activate ProxyCommand:<br/>netbird ssh proxy &lt;peer&gt; 22]

    H --> I[Local SSH Proxy Started]
    I --> J[OpenSSH connects to proxy via stdio]

    J --> K[Proxy requests JWT from daemon]

    K --> K1{Check JWT cache}
    K1 -->|Cache valid| K2[Use cached JWT token]
    K1 -->|No cache/expired| K3[OIDC flow:<br/>User authorizes via IDP callback]
    K3 --> K4[Receive & cache JWT token]
    K4 --> L[Proxy connects to peer SSH server]
    K2 --> L

    L --> M[Proxy sends JWT authentication]

    M --> N{JWT Valid?}
    N -->|No| O[Connection Rejected]
    N -->|Yes| P[Proxy establishes session]

    P --> Q[Bidirectional forwarding:<br/>OpenSSH ↔ Proxy ↔ Peer SSH Server]

    Q --> R[User interacts with remote shell]

    style H fill:#f57c00,color:#fff
    style I fill:#1976d2,color:#fff
    style K fill:#1976d2,color:#fff
    style K1 fill:#1976d2,color:#fff
    style K3 fill:#e65100,color:#fff
    style Q fill:#388e3c,color:#fff

    classDef proxyBox fill:#1565c0,stroke:#0d47a1,stroke-width:2px,color:#fff
    class I,K,L,M,P,Q proxyBox
Loading

Issue ticket number and link

Fixes #4759 #4672 #4456 #4039 #3985 #2498 #4457

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)
  • Extended the README / documentation, if necessary

Documentation

Select exactly one:


By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Summary by CodeRabbit

  • New Features
    • Complete SSH support: embedded SSH server and client (JWT-backed auth), SFTP, local/remote port forwarding, SSH proxy, host-key retrieval, session listing, SSH client config generation, server detection, and UI/WASM controls including SSH JWT cache TTL.
  • Bug Fixes
    • License check made quieter and more robust by suppressing noisy errors during scanning.

@lixmal lixmal marked this pull request as ready for review June 20, 2025 15:25
Copilot AI review requested due to automatic review settings June 20, 2025 15:25

This comment was marked as outdated.

@lixmal lixmal marked this pull request as draft June 22, 2025 14:49
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 29

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
client/ui/client_ui.go (1)

1247-1303: Reset JWT TTL UI when the server clears it

When the management config omits SSHJWTCacheTTL, we neither reset s.sshJWTCacheTTL nor clear iSSHJWTCacheTTL. The old TTL stays visible and will be re-sent on the next save, effectively recreating a cache the server just disabled. Please reset the state/UI when the value is absent.

-	if cfg.SSHJWTCacheTTL != nil {
-		s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL
-	}
+	if cfg.SSHJWTCacheTTL != nil {
+		s.sshJWTCacheTTL = *cfg.SSHJWTCacheTTL
+	} else {
+		s.sshJWTCacheTTL = 0
+	}-		if cfg.SSHJWTCacheTTL != nil {
-			s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL))
-		}
+		if cfg.SSHJWTCacheTTL != nil {
+			s.iSSHJWTCacheTTL.SetText(strconv.Itoa(*cfg.SSHJWTCacheTTL))
+		} else {
+			s.iSSHJWTCacheTTL.SetText("")
+		}
🧹 Nitpick comments (6)
client/internal/peer/env.go (1)

13-18: Appropriate platform-specific handling for JS/Wasm environments.

The logic correctly forces relay connections for JavaScript/Wasm environments where direct peer-to-peer networking is unavailable due to browser restrictions. This is a sound architectural choice that enables browser-based clients while preserving existing behavior for native platforms.

Consider adding a brief comment explaining the rationale:

 func isForceRelayed() bool {
+	// JS/Wasm environments (browsers) cannot establish direct peer connections
 	if runtime.GOOS == "js" {
 		return true
 	}
 	return strings.EqualFold(os.Getenv(EnvKeyNBForceRelay), "true")
 }
client/internal/peer/conn.go (1)

669-671: Document the platform-specific behavior.

The platform guard on line 669 creates divergent behavior for JS/WASM: the ICE disconnection check is skipped entirely, allowing the connection to be considered healthy via relay alone. On other platforms, the function enforces that ICE must be connected or in-progress.

Adding a brief inline comment would help future maintainers understand why JS/WASM environments receive special treatment.

Apply this diff to add documentation:

+	// On JS/WASM, skip strict ICE checks to allow relay-only connections,
+	// as browser environments may have ICE limitations.
 	if runtime.GOOS != "js" && conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() {
 		return false
 	}
client/ssh/server/winpty/conpty_test.go (3)

121-133: Consider using defer for handle cleanup.

The handles are cleaned up at the end, but if any assertion fails before line 132, the handles will leak. Using defer closeHandles(...) immediately after successful creation would ensure cleanup even on assertion failure.

Apply this diff:

 func TestCreateConPtyPipes(t *testing.T) {
   inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes()
   require.NoError(t, err, "Should create ConPty pipes successfully")
+  defer closeHandles(inputRead, inputWrite, outputRead, outputWrite)

   // Verify all handles are valid
   assert.NotEqual(t, windows.InvalidHandle, inputRead, "Input read handle should be valid")
   assert.NotEqual(t, windows.InvalidHandle, inputWrite, "Input write handle should be valid")
   assert.NotEqual(t, windows.InvalidHandle, outputRead, "Output read handle should be valid")
   assert.NotEqual(t, windows.InvalidHandle, outputWrite, "Output write handle should be valid")
-
-  // Clean up handles
-  closeHandles(inputRead, inputWrite, outputRead, outputWrite)
 }

135-147: Consider using defer for ConPty handle cleanup.

The ConPty handle cleanup at line 145 will be skipped if the assertion at line 142 fails, potentially leaking the pseudo-console. Using defer would ensure cleanup regardless of assertion outcomes.

Apply this diff:

   hPty, err := createConPty(80, 24, inputRead, outputWrite)
   require.NoError(t, err, "Should create ConPty successfully")
   assert.NotEqual(t, windows.InvalidHandle, hPty, "ConPty handle should be valid")
+  defer func() {
+    if ret, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); ret == 0 {
+      t.Logf("Failed to close ConPty")
+    }
+  }()
-
-  // Clean up ConPty
-  ret, _, _ := procClosePseudoConsole.Call(uintptr(hPty))
-  assert.NotEqual(t, uintptr(0), ret, "Should close ConPty successfully")
 }

149-187: LGTM with minor observation.

The test covers important edge cases including empty environments and empty strings. Note that the hasError field is defined but never used (all cases set it to false). If there are malformed environment scenarios that should error, consider adding test cases for them.

client/proto/daemon.proto (1)

275-285: Field numbering is non-sequential in GetConfigResponse.

The SSH-related fields use non-sequential numbering: 21, 24, 22, 23, 25. While protobuf permits this, sequential numbering (21, 22, 23, 24, 25) is clearer and suggests these fields were added together. This pattern can cause confusion during maintenance.

Consider renumbering for clarity if this is early in the release cycle:

 bool enableSSHRoot = 21;
-bool enableSSHSFTP = 24;
-bool enableSSHLocalPortForwarding = 22;
-bool enableSSHRemotePortForwarding = 23;
-bool disableSSHAuth = 25;
-int32 sshJWTCacheTTL = 26;
+bool enableSSHLocalPortForwarding = 22;
+bool enableSSHRemotePortForwarding = 23;
+bool enableSSHSFTP = 24;
+bool disableSSHAuth = 25;
+int32 sshJWTCacheTTL = 26;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbfc8a5 and 79c400a.

⛔ Files ignored due to path filters (4)
  • client/proto/daemon.pb.go is excluded by !**/*.pb.go
  • client/proto/daemon_grpc.pb.go is excluded by !**/*.pb.go
  • go.sum is excluded by !**/*.sum
  • shared/management/proto/management.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (105)
  • .github/workflows/check-license-dependencies.yml (1 hunks)
  • client/android/client.go (1 hunks)
  • client/android/preferences.go (1 hunks)
  • client/cmd/root.go (0 hunks)
  • client/cmd/ssh.go (1 hunks)
  • client/cmd/ssh_exec_unix.go (1 hunks)
  • client/cmd/ssh_sftp_unix.go (1 hunks)
  • client/cmd/ssh_sftp_windows.go (1 hunks)
  • client/cmd/ssh_test.go (1 hunks)
  • client/cmd/testutil_test.go (1 hunks)
  • client/cmd/up.go (3 hunks)
  • client/embed/embed.go (4 hunks)
  • client/firewall/uspfilter/filter.go (1 hunks)
  • client/firewall/uspfilter/filter_test.go (2 hunks)
  • client/firewall/uspfilter/nat_stateful_test.go (1 hunks)
  • client/internal/acl/manager.go (0 hunks)
  • client/internal/acl/manager_test.go (0 hunks)
  • client/internal/connect.go (2 hunks)
  • client/internal/debug/debug.go (1 hunks)
  • client/internal/engine.go (11 hunks)
  • client/internal/engine_ssh.go (1 hunks)
  • client/internal/engine_test.go (8 hunks)
  • client/internal/login.go (2 hunks)
  • client/internal/peer/conn.go (1 hunks)
  • client/internal/peer/env.go (2 hunks)
  • client/internal/peer/status.go (3 hunks)
  • client/internal/profilemanager/config.go (3 hunks)
  • client/internal/profilemanager/config_test.go (1 hunks)
  • client/internal/profilemanager/profilemanager.go (1 hunks)
  • client/internal/routemanager/dynamic/route.go (1 hunks)
  • client/ios/NetBirdSDK/client.go (1 hunks)
  • client/proto/daemon.proto (7 hunks)
  • client/server/jwt_cache.go (1 hunks)
  • client/server/network.go (1 hunks)
  • client/server/server.go (9 hunks)
  • client/server/server_test.go (1 hunks)
  • client/server/setconfig_test.go (5 hunks)
  • client/server/state_generic.go (1 hunks)
  • client/server/state_linux.go (1 hunks)
  • client/ssh/client.go (0 hunks)
  • client/ssh/client/client.go (1 hunks)
  • client/ssh/client/client_test.go (1 hunks)
  • client/ssh/client/terminal_unix.go (1 hunks)
  • client/ssh/client/terminal_windows.go (1 hunks)
  • client/ssh/common.go (1 hunks)
  • client/ssh/config/manager.go (1 hunks)
  • client/ssh/config/manager_test.go (1 hunks)
  • client/ssh/config/shutdown_state.go (1 hunks)
  • client/ssh/detection/detection.go (1 hunks)
  • client/ssh/login.go (0 hunks)
  • client/ssh/lookup.go (0 hunks)
  • client/ssh/lookup_darwin.go (0 hunks)
  • client/ssh/proxy/proxy.go (1 hunks)
  • client/ssh/proxy/proxy_test.go (1 hunks)
  • client/ssh/server.go (0 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/command_execution_js.go (1 hunks)
  • client/ssh/server/command_execution_unix.go (1 hunks)
  • client/ssh/server/command_execution_windows.go (1 hunks)
  • client/ssh/server/compatibility_test.go (1 hunks)
  • client/ssh/server/executor_unix.go (1 hunks)
  • client/ssh/server/executor_unix_test.go (1 hunks)
  • client/ssh/server/executor_windows.go (1 hunks)
  • client/ssh/server/jwt_test.go (1 hunks)
  • client/ssh/server/port_forwarding.go (1 hunks)
  • client/ssh/server/server.go (1 hunks)
  • client/ssh/server/server_config_test.go (1 hunks)
  • client/ssh/server/server_test.go (1 hunks)
  • client/ssh/server/session_handlers.go (1 hunks)
  • client/ssh/server/session_handlers_js.go (1 hunks)
  • client/ssh/server/sftp.go (1 hunks)
  • client/ssh/server/sftp_js.go (1 hunks)
  • client/ssh/server/sftp_test.go (1 hunks)
  • client/ssh/server/sftp_unix.go (1 hunks)
  • client/ssh/server/sftp_windows.go (1 hunks)
  • client/ssh/server/shell.go (1 hunks)
  • client/ssh/server/test.go (1 hunks)
  • client/ssh/server/user_utils.go (1 hunks)
  • client/ssh/server/user_utils_js.go (1 hunks)
  • client/ssh/server/user_utils_test.go (1 hunks)
  • client/ssh/server/userswitching_js.go (1 hunks)
  • client/ssh/server/userswitching_unix.go (1 hunks)
  • client/ssh/server/userswitching_windows.go (1 hunks)
  • client/ssh/server/winpty/conpty.go (1 hunks)
  • client/ssh/server/winpty/conpty_test.go (1 hunks)
  • client/ssh/server_mock.go (0 hunks)
  • client/ssh/server_test.go (0 hunks)
  • client/ssh/ssh.go (3 hunks)
  • client/ssh/testutil/user_helpers.go (1 hunks)
  • client/ssh/window_freebsd.go (0 hunks)
  • client/ssh/window_unix.go (0 hunks)
  • client/ssh/window_windows.go (0 hunks)
  • client/system/info.go (2 hunks)
  • client/ui/client_ui.go (6 hunks)
  • client/wasm/cmd/main.go (3 hunks)
  • client/wasm/internal/ssh/client.go (3 hunks)
  • client/wasm/internal/ssh/key.go (0 hunks)
  • go.mod (8 hunks)
  • management/internals/server/modules.go (1 hunks)
  • management/server/account.go (14 hunks)
  • management/server/account/manager.go (3 hunks)
  • management/server/account_test.go (26 hunks)
  • management/server/auth/manager.go (2 hunks)
  • management/server/auth/manager_mock.go (3 hunks)
  • management/server/auth/manager_test.go (5 hunks)
💤 Files with no reviewable changes (14)
  • client/internal/acl/manager_test.go
  • client/internal/acl/manager.go
  • client/ssh/lookup.go
  • client/ssh/server_mock.go
  • client/ssh/window_freebsd.go
  • client/ssh/lookup_darwin.go
  • client/ssh/server_test.go
  • client/ssh/client.go
  • client/cmd/root.go
  • client/wasm/internal/ssh/key.go
  • client/ssh/login.go
  • client/ssh/window_unix.go
  • client/ssh/server.go
  • client/ssh/window_windows.go
🧰 Additional context used
🧬 Code graph analysis (69)
client/server/state_generic.go (1)
client/ssh/config/shutdown_state.go (1)
  • ShutdownState (4-7)
client/internal/login.go (1)
management/client/grpc.go (1)
  • c (367-373)
client/server/state_linux.go (2)
client/ssh/config/manager.go (1)
  • Manager (86-89)
client/ssh/config/shutdown_state.go (1)
  • ShutdownState (4-7)
client/ssh/server/test.go (2)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server_mock.go (1)
  • srv (39-44)
client/ssh/config/manager_test.go (1)
client/ssh/config/manager.go (4)
  • Manager (86-89)
  • PeerSSHInfo (92-96)
  • MaxPeersForSSHConfig (23-23)
  • EnvForceSSHConfig (21-21)
client/ssh/server/sftp_windows.go (4)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (1)
  • NewPrivilegeDropper (41-43)
client/ssh/server.go (2)
  • srv (136-214)
  • srv (216-244)
client/cmd/ssh_exec_unix.go (1)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • ExecutorConfig (27-35)
client/ssh/server/server_test.go (3)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (3)
  • Config (141-147)
  • New (151-163)
  • Server (108-131)
client/ssh/server.go (1)
  • Stop (35-44)
client/ssh/server/session_handlers_js.go (2)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/config/shutdown_state.go (1)
client/ssh/config/manager.go (1)
  • Manager (86-89)
client/ssh/server/command_execution_js.go (1)
client/ssh/server.go (1)
  • srv (136-214)
client/ssh/server/userswitching_windows.go (3)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/executor_windows.go (2)
  • WindowsExecutorConfig (30-41)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (1)
  • NewPrivilegeDropper (41-43)
client/ssh/server/shell.go (2)
client/ssh/server/server.go (1)
  • InternalSSHPort (33-33)
client/ssh/server.go (4)
  • getUserShell (260-274)
  • prepareUserEnv (119-125)
  • srv (136-214)
  • srv (216-244)
client/server/server_test.go (1)
management/server/account.go (1)
  • BuildManager (191-296)
management/internals/server/modules.go (1)
management/server/account.go (1)
  • BuildManager (191-296)
client/ssh/server/session_handlers.go (3)
client/ssh/server/server.go (3)
  • Server (108-131)
  • New (151-163)
  • PrivilegedUserError (51-53)
client/ssh/server/port_forwarding.go (2)
  • SessionKey (16-16)
  • ConnectionKey (19-19)
client/ssh/server/user_utils.go (2)
  • ErrPrivilegeRequired (15-15)
  • ErrPrivilegedUserSwitch (16-16)
client/cmd/ssh_sftp_unix.go (1)
client/ssh/server/executor_unix.go (5)
  • NewPrivilegeDropper (41-43)
  • ExecutorConfig (27-35)
  • ExitCodePrivilegeDropFail (21-21)
  • ExitCodeShellExecFail (22-22)
  • ExitCodeSuccess (20-20)
management/server/account/manager.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/server/sftp.go (2)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/user_utils.go (2)
  • PrivilegeCheckRequest (42-47)
  • FeatureSFTP (38-38)
client/ssh/server/executor_unix_test.go (1)
client/ssh/server/executor_unix.go (1)
  • ExecutorConfig (27-35)
client/wasm/cmd/main.go (1)
client/ssh/detection/detection.go (2)
  • ServerType (26-26)
  • DetectSSHServerType (59-99)
client/ssh/common.go (5)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/config/manager.go (1)
  • New (99-105)
client/proto/daemon_grpc.pb.go (1)
  • DaemonServiceClient (20-73)
client/proto/daemon.pb.go (9)
  • GetPeerSSHHostKeyRequest (4716-4722)
  • GetPeerSSHHostKeyRequest (4735-4735)
  • GetPeerSSHHostKeyRequest (4750-4752)
  • RequestJWTAuthRequest (4835-4841)
  • RequestJWTAuthRequest (4854-4854)
  • RequestJWTAuthRequest (4869-4871)
  • WaitJWTTokenRequest (4981-4989)
  • WaitJWTTokenRequest (5002-5002)
  • WaitJWTTokenRequest (5017-5019)
client/ssh/server.go (1)
  • srv (75-86)
client/ssh/server/command_execution_unix.go (2)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server.go (2)
  • srv (136-214)
  • srv (216-244)
client/cmd/testutil_test.go (1)
management/server/account.go (1)
  • BuildManager (191-296)
client/firewall/uspfilter/nat_stateful_test.go (4)
client/firewall/uspfilter/filter.go (1)
  • Create (156-158)
client/firewall/uspfilter/filter_test.go (1)
  • IFaceMock (32-37)
client/iface/device/device_filter.go (1)
  • PacketFilter (11-26)
client/iface/iface.go (1)
  • DefaultMTU (28-28)
client/ssh/server/sftp_unix.go (1)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/command_execution.go (3)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server.go (2)
  • srv (136-214)
  • srv (216-244)
client/ssh/client.go (1)
  • c (24-85)
client/cmd/ssh_sftp_windows.go (1)
client/ssh/server/executor_windows.go (3)
  • ExitCodeValidationFail (27-27)
  • ExitCodeShellExecFail (26-26)
  • ExitCodeSuccess (22-22)
client/ssh/client/terminal_windows.go (3)
client/ssh/client/client.go (1)
  • Client (37-44)
client/ssh/client.go (2)
  • c (24-85)
  • client (14-16)
client/ssh/window_windows.go (1)
  • setWinSize (7-9)
client/wasm/internal/ssh/client.go (3)
client/embed/embed.go (1)
  • Client (33-41)
client/ssh/common.go (1)
  • CreateHostKeyCallback (140-159)
client/internal/config.go (1)
  • PrivateKey (66-115)
client/embed/embed.go (2)
client/ssh/common.go (2)
  • ErrPeerNotFound (27-27)
  • VerifyHostKey (113-128)
client/internal/engine.go (1)
  • Engine (138-211)
client/ssh/server/compatibility_test.go (7)
client/ssh/proxy/proxy_test.go (1)
  • TestMain (35-55)
client/ssh/client/client_test.go (1)
  • TestMain (26-42)
client/ssh/testutil/user_helpers.go (3)
  • CleanupTestUsers (87-93)
  • GetTestUsername (20-45)
  • IsCI (48-59)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (2)
  • Config (141-147)
  • New (151-163)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/ssh/server_test.go (3)
  • TestServer_AddAuthorizedKey (11-49)
  • TestServer_PubKeyHandler (81-121)
  • TestServer_RemoveAuthorizedKey (51-79)
client/ssh/proxy/proxy.go (5)
client/proto/daemon_grpc.pb.go (2)
  • DaemonServiceClient (20-73)
  • NewDaemonServiceClient (79-81)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/ssh/common.go (3)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/ssh/detection/detection.go (1)
  • ProxyIdentifier (18-18)
version/version.go (1)
  • NetbirdVersion (18-20)
client/cmd/ssh.go (7)
client/ssh/server/server.go (2)
  • DefaultSSHPort (30-30)
  • New (151-163)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/ssh/client.go (1)
  • c (24-85)
management/server/auth/manager.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/internal/peer/status.go (5)
client/ssh/server/server.go (1)
  • New (151-163)
management/server/peer.go (2)
  • am (861-903)
  • am (160-232)
client/internal/peer/status_test.go (1)
  • TestStatus_UpdatePeerFQDN (61-78)
management/server/mock_server/account_mock.go (1)
  • am (429-434)
client/ssh/server.go (1)
  • srv (75-86)
client/ssh/server/user_utils_test.go (2)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckRequest (42-47)
client/ssh/server/winpty/conpty.go (2)
client/ssh/client.go (1)
  • c (24-85)
client/ssh/server.go (1)
  • srv (136-214)
management/server/auth/manager_test.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/server/user_utils.go (1)
client/ssh/server.go (1)
  • srv (136-214)
client/internal/profilemanager/config.go (1)
client/internal/config.go (6)
  • config (226-428)
  • createNewConfig (192-203)
  • ManagementURL (45-63)
  • update (205-224)
  • PrivateKey (66-115)
  • UpdateConfig (151-157)
client/ssh/client/terminal_unix.go (1)
client/ssh/client/client.go (1)
  • Client (37-44)
client/ssh/server/sftp_test.go (3)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (2)
  • Config (141-147)
  • New (151-163)
client/ssh/server.go (1)
  • Stop (35-44)
client/ssh/server/server_config_test.go (5)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (2)
  • Config (141-147)
  • New (151-163)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/ssh/client/client.go (2)
  • Dial (284-310)
  • DialOptions (275-281)
client/ssh/server.go (1)
  • srv (136-214)
client/internal/peer/conn.go (1)
client/internal/peer/worker/state.go (1)
  • StatusDisconnected (10-10)
client/server/server.go (5)
client/ssh/server/server.go (2)
  • Server (108-131)
  • New (151-163)
client/proto/daemon.pb.go (21)
  • GetPeerSSHHostKeyRequest (4716-4722)
  • GetPeerSSHHostKeyRequest (4735-4735)
  • GetPeerSSHHostKeyRequest (4750-4752)
  • GetPeerSSHHostKeyResponse (4762-4774)
  • GetPeerSSHHostKeyResponse (4787-4787)
  • GetPeerSSHHostKeyResponse (4802-4804)
  • RequestJWTAuthRequest (4835-4841)
  • RequestJWTAuthRequest (4854-4854)
  • RequestJWTAuthRequest (4869-4871)
  • RequestJWTAuthResponse (4881-4899)
  • RequestJWTAuthResponse (4912-4912)
  • RequestJWTAuthResponse (4927-4929)
  • WaitJWTTokenRequest (4981-4989)
  • WaitJWTTokenRequest (5002-5002)
  • WaitJWTTokenRequest (5017-5019)
  • WaitJWTTokenResponse (5036-5046)
  • WaitJWTTokenResponse (5059-5059)
  • WaitJWTTokenResponse (5074-5076)
  • GetConfigResponse (1097-1132)
  • GetConfigResponse (1145-1145)
  • GetConfigResponse (1160-1162)
client/internal/engine.go (1)
  • Engine (138-211)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/internal/auth/oauth.go (1)
  • NewOAuthFlow (69-81)
client/ssh/client/client_test.go (5)
client/ssh/testutil/user_helpers.go (4)
  • CleanupTestUsers (87-93)
  • GetTestUsername (20-45)
  • IsCI (48-59)
  • IsSystemAccount (62-76)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (3)
  • Config (141-147)
  • New (151-163)
  • Server (108-131)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • DialOptions (275-281)
  • Client (37-44)
management/server/auth/manager_mock.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/firewall/uspfilter/filter_test.go (2)
client/firewall/uspfilter/filter.go (1)
  • Create (156-158)
client/iface/iface.go (1)
  • DefaultMTU (28-28)
client/ssh/testutil/user_helpers.go (2)
client/ssh/lookup_darwin.go (1)
  • idUserNameLookup (26-47)
client/ssh/server.go (1)
  • prepareUserEnv (119-125)
client/ssh/server/server.go (5)
shared/auth/jwt/validator.go (2)
  • Validator (56-63)
  • NewValidator (72-85)
shared/auth/jwt/extractor.go (3)
  • ClaimsExtractor (34-37)
  • NewClaimsExtractor (58-68)
  • WithAudience (43-47)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/detection/detection.go (2)
  • ServerIdentifier (16-16)
  • JWTRequiredMarker (20-20)
version/version.go (1)
  • NetbirdVersion (18-20)
client/ui/client_ui.go (4)
client/ssh/server/server.go (1)
  • New (151-163)
client/server/server.go (1)
  • New (99-110)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (3)
  • SetConfigRequest (3788-3829)
  • SetConfigRequest (3842-3842)
  • SetConfigRequest (3857-3859)
client/ssh/server/executor_unix.go (1)
client/ssh/server/executor_windows.go (4)
  • NewPrivilegeDropper (45-47)
  • PrivilegeDropper (43-43)
  • ExitCodeSuccess (22-22)
  • ExitCodeShellExecFail (26-26)
client/internal/engine_ssh.go (6)
client/internal/engine.go (1)
  • Engine (138-211)
client/ssh/server/server.go (5)
  • New (151-163)
  • JWTConfig (133-138)
  • Config (141-147)
  • InternalSSHPort (33-33)
  • Server (108-131)
client/ssh/config/manager.go (2)
  • New (99-105)
  • PeerSSHInfo (92-96)
client/firewall/manager/protocol.go (1)
  • ProtocolTCP (9-9)
shared/management/proto/management.pb.go (9)
  • SSHConfig (2091-2102)
  • SSHConfig (2117-2117)
  • SSHConfig (2132-2134)
  • JWTConfig (1619-1628)
  • JWTConfig (1643-1643)
  • JWTConfig (1658-1660)
  • RemotePeerConfig (2007-2021)
  • RemotePeerConfig (2036-2036)
  • RemotePeerConfig (2051-2053)
client/ssh/config/shutdown_state.go (1)
  • ShutdownState (4-7)
client/ssh/server/port_forwarding.go (2)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckRequest (42-47)
client/ssh/server/command_execution_windows.go (4)
client/ssh/server/server.go (1)
  • Server (108-131)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/winpty/conpty.go (3)
  • PtyConfig (36-42)
  • UserConfig (45-48)
  • ExecutePtyWithUserToken (59-71)
client/ssh/server/jwt_test.go (6)
client/ssh/server/server.go (4)
  • JWTConfig (133-138)
  • Config (141-147)
  • New (151-163)
  • Server (108-131)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/ssh/detection/detection.go (5)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
  • ServerTypeNetBirdNoJWT (30-30)
  • ServerTypeNetBirdJWT (29-29)
client/ssh/testutil/user_helpers.go (1)
  • GetTestUsername (20-45)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • Client (37-44)
  • DialOptions (275-281)
shared/auth/jwt/validator.go (2)
  • JSONWebKey (44-54)
  • Jwks (26-29)
client/internal/connect.go (2)
util/common.go (1)
  • ReturnBoolWithDefaultTrue (51-58)
client/internal/config.go (1)
  • config (226-428)
client/ssh/proxy/proxy_test.go (7)
client/ssh/testutil/user_helpers.go (2)
  • CleanupTestUsers (87-93)
  • GetTestUsername (20-45)
client/ssh/proxy/proxy.go (2)
  • SSHProxy (37-43)
  • New (45-59)
client/proto/daemon_grpc.pb.go (1)
  • NewDaemonServiceClient (79-81)
client/ssh/server/server.go (4)
  • Config (141-147)
  • JWTConfig (133-138)
  • New (151-163)
  • Server (108-131)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/proto/daemon.pb.go (12)
  • GetPeerSSHHostKeyRequest (4716-4722)
  • GetPeerSSHHostKeyRequest (4735-4735)
  • GetPeerSSHHostKeyRequest (4750-4752)
  • GetPeerSSHHostKeyResponse (4762-4774)
  • GetPeerSSHHostKeyResponse (4787-4787)
  • GetPeerSSHHostKeyResponse (4802-4804)
  • RequestJWTAuthResponse (4881-4899)
  • RequestJWTAuthResponse (4912-4912)
  • RequestJWTAuthResponse (4927-4929)
  • WaitJWTTokenResponse (5036-5046)
  • WaitJWTTokenResponse (5059-5059)
  • WaitJWTTokenResponse (5074-5076)
shared/auth/jwt/validator.go (2)
  • JSONWebKey (44-54)
  • Jwks (26-29)
client/ssh/config/manager.go (1)
client/ssh/common.go (3)
  • NetBirdSSHConfigFile (19-19)
  • UnixSSHConfigDir (21-21)
  • WindowsSSHConfigDir (22-22)
client/android/preferences.go (3)
client/ios/NetBirdSDK/preferences.go (1)
  • Preferences (8-10)
client/internal/profilemanager/config.go (1)
  • ReadConfig (772-774)
client/internal/config.go (1)
  • config (226-428)
client/internal/engine.go (1)
client/ssh/config/shutdown_state.go (1)
  • ShutdownState (4-7)
client/ssh/server/executor_windows.go (2)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • PrivilegeDropper (38-38)
client/ssh/server/server.go (2)
  • New (151-163)
  • Server (108-131)
client/ssh/server/userswitching_unix.go (4)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • ExecutorConfig (27-35)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server.go (1)
  • prepareUserEnv (119-125)
client/ssh/login.go (1)
  • getLoginCmd (12-36)
client/internal/engine_test.go (4)
shared/management/proto/management.pb.go (15)
  • NetworkMap (1857-1887)
  • NetworkMap (1902-1902)
  • NetworkMap (1917-1919)
  • PeerConfig (1757-1773)
  • PeerConfig (1788-1788)
  • PeerConfig (1803-1805)
  • SSHConfig (2091-2102)
  • SSHConfig (2117-2117)
  • SSHConfig (2132-2134)
  • JWTConfig (1619-1628)
  • JWTConfig (1643-1643)
  • JWTConfig (1658-1660)
  • RemotePeerConfig (2007-2021)
  • RemotePeerConfig (2036-2036)
  • RemotePeerConfig (2051-2053)
client/ssh/server/server.go (1)
  • JWTConfig (133-138)
client/internal/engine.go (2)
  • Engine (138-211)
  • EngineConfig (81-135)
management/server/account.go (1)
  • BuildManager (191-296)
management/server/account.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/client/client.go (5)
client/ssh/detection/detection.go (3)
  • Timeout (23-23)
  • Dialer (35-37)
  • DetectSSHServerType (59-99)
client/ssh/common.go (3)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/proto/daemon_grpc.pb.go (1)
  • NewDaemonServiceClient (79-81)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/cmd/up.go (1)
client/internal/config.go (1)
  • config (226-428)
management/server/account_test.go (2)
shared/auth/user.go (1)
  • UserAuth (7-28)
management/server/account.go (1)
  • BuildManager (191-296)
🪛 Gitleaks (8.28.0)
client/ssh/server/jwt_test.go

[high] 593-593: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (25)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: Windows
  • GitHub Check: Linux
  • GitHub Check: Darwin
  • GitHub Check: release_ui_darwin
  • GitHub Check: release
  • GitHub Check: Client / Unit
  • GitHub Check: release_ui
  • GitHub Check: Android / Build
  • GitHub Check: iOS / Build
  • GitHub Check: JS / Lint
🔇 Additional comments (41)
client/ios/NetBirdSDK/client.go (1)

23-24: Import reordering looks good.

The import reorganization has no functional impact. Both imports are used correctly in the code and there are no conflicts or ordering-related issues.

client/server/network.go (1)

3-16: Import reordering aligns with Go conventions.

The import block has been reorganized to follow standard Go practices: standard library → third-party → local/internal packages. The domain import is correctly positioned with other NetBird internal imports and is properly used throughout the file (e.g., domain.List at line 21, domain.Domain at line 88).

.github/workflows/check-license-dependencies.yml (2)

22-33: Process substitution refactoring fixes variable scoping bug.

The switch from a piped while loop to process substitution (done < <(find ...)) is a subtle but important correctness fix. In the original approach, the loop would execute in a subshell, causing FOUND_ISSUES=1 assignments inside the loop to be lost after the subshell exits. The new structure keeps the loop in the current shell context, properly preserving the FOUND_ISSUES accumulator. Combined with improved error handling (2>/dev/null, || true), the script is now more robust and correct.


38-38: License error message is now more explicit and actionable.

The updated message clearly identifies the AGPLv3 licensing constraint and prohibits import of affected packages by BSD-licensed code. This aligns well with the PR's broader effort to enforce stricter license compliance across the codebase.

client/firewall/uspfilter/filter.go (5)

38-42: LGTM: Clean composite key design.

The serviceKey struct properly encapsulates the protocol/port pair for netstack service identification. The struct is suitable for use as a map key since both fields are comparable types.


1406-1415: LGTM: Proper service registration with correct locking.

The method correctly constructs the composite key and registers the service with appropriate mutex protection. The debug logging is helpful for troubleshooting.


1417-1425: LGTM: Clean unregistration logic.

The unregistration correctly mirrors the registration logic with proper key construction and mutex protection.


1427-1439: LGTM: Protocol conversion mapping is correct.

The conversion from nftypes.Protocol to gopacket.LayerType handles the expected protocol types correctly. The default case returning LayerType(0) is acceptable for this internal helper method.


1465-1487: LGTM: Service lookup logic is correct.

The method properly constructs the composite key for lookup and uses read lock appropriately. The logic correctly handles edge cases (insufficient layers, non-TCP/UDP protocols).

client/firewall/uspfilter/filter_test.go (1)

1119-1252: LGTM: Comprehensive test coverage for forwarding logic.

The test thoroughly validates the shouldForward method across all key scenarios:

  • Local forwarding disabled
  • Traffic to other local interfaces
  • NetBird IP handling with/without netstack
  • Service registration impact

The table-driven approach and helper function make the test maintainable and easy to extend.

client/firewall/uspfilter/nat_stateful_test.go (2)

15-48: LGTM: Test validates core DNAT behavior.

The test correctly verifies:

  1. Inbound traffic to port 22 is translated to 22022 (aligns with SSH port changes in PR)
  2. Return traffic is not double-translated (important for stateful NAT)

The test logic is sound and the assertions are appropriate.


51-85: LGTM: Multi-rule DNAT test is thorough.

The test properly validates that multiple DNAT rules can be configured and work correctly for different peers. This is an important scenario for real-world deployments with multiple SSH-enabled peers.

client/ssh/server/shell.go (6)

18-23: LGTM!

Constants are appropriately defined with clear naming and security annotation.


25-34: LGTM!

Platform detection and shell selection dispatch is clean and correct.


36-50: LGTM!

The security rationale for avoiding cmd.exe is sound, and the PowerShell preference order with hardcoded fallback is reasonable for Windows environments.


52-64: LGTM!

Fallback chain from /etc/passwd → SHELL env → /bin/sh is sensible.


111-149: LGTM!

Environment variable whitelisting logic is correct and follows standard SSH practices. The exact and prefix matching handles common locale, terminal, and editor variables appropriately.


151-175: LGTM!

SSH environment variable preparation correctly follows SSH protocol standards with appropriate error handling and fallbacks. The use of InternalSSHPort constant is consistent with the codebase.

client/ssh/server/winpty/conpty_test.go (4)

55-119: LGTM!

The test provides comprehensive coverage of Windows command-line argument escaping and quoting behavior, including edge cases like nested quotes, backslashes, empty arguments, and paths with spaces.


189-211: LGTM!

The test properly manages Windows tokens with appropriate cleanup, and correctly skips in short mode since token operations can be expensive. Resource management is thorough with both deferred and explicit cleanup.


241-267: Verify double-close safety.

Similar to TestWindowsHandleReader, the write handle is explicitly closed at line 256, then defer closeHandles(...) at line 246 will attempt to close it again. Ensure that closeHandles safely handles already-closed handles.


269-289: LGTM!

The benchmark appropriately measures the full ConPty creation lifecycle including pipe setup and cleanup. Error handling with b.Fatal ensures the benchmark stops on failure, and cleanup is comprehensive.

client/ssh/server/userswitching_windows.go (1)

89-99: LGTM - Windows executor command creation looks correct.

The validation and command creation flow is appropriate for Windows user switching. The username validation before executor creation provides proper input sanitization.

client/server/state_linux.go (1)

11-19: LGTM - SSH config state registration.

The SSH config shutdown state is properly registered alongside other shutdown states for Linux platforms.

client/server/state_generic.go (1)

9-15: LGTM - SSH config state registration.

The SSH config shutdown state is properly registered for non-Linux platforms.

client/internal/peer/status.go (2)

70-70: LGTM - SSH host key field addition.

The SSHHostKey field properly extends the peer state to track SSH host keys.


576-590: LGTM - UpdatePeerSSHHostKey implementation.

The method follows the established pattern for peer state updates with proper mutex locking and error handling.

management/server/account_test.go (1)

2951-2951: Let me verify the test file one more time to ensure I understand the full context:

No changes required. The nil config parameter is safe.

Based on comprehensive verification:

  1. BuildManager stores the config parameter directly without any nil checks (expected pattern)
  2. The config field is never dereferenced or accessed anywhere in DefaultAccountManager
  3. This is a valid Go testing pattern—nil pointers can be safely passed for unused fields

The test scenario is correct as-is.

client/internal/profilemanager/profilemanager.go (1)

136-152: LGTM!

The GetLoginHint() function is well-implemented with appropriate error handling and debug logging. The function correctly retrieves the email from the active profile state to use as a login hint, gracefully returning an empty string on any failure.

client/ssh/server/userswitching_js.go (1)

1-8: LGTM!

The JS/WASM stub correctly returns errNotSupported for user switching functionality, which is not available in the JavaScript/WebAssembly environment.

client/ssh/server/sftp_js.go (1)

1-12: LGTM!

The JS/WASM stub appropriately returns errNotSupported for parseUserCredentials, which is consistent with other JS/WASM-specific implementations in this SSH server package.

client/internal/login.go (1)

127-131: LGTM!

The SSH-related feature flags are correctly propagated through both the login and registration flows. The implementation is consistent across both doMgmLogin and registerPeer functions.

Also applies to: 158-162

client/server/setconfig_test.go (3)

75-75: LGTM!

The SSH JWT cache TTL field is properly tested with appropriate variable definition, request population, and assertions. The test correctly verifies that the TTL value is persisted to the configuration.

Also applies to: 106-106, 151-152


173-204: LGTM!

The expectedFields map is correctly updated to include all SSH-related configuration fields. The comprehensive field coverage ensures that any future additions will be caught by the reflection-based test.


233-263: LGTM!

The CLI flag mapping is correctly updated to include all SSH-related flags. This ensures that the SSH configuration flags are properly wired from the CLI to the SetConfigRequest.

management/internals/server/modules.go (1)

69-69: LGTM!

The addition of s.config as the second parameter correctly aligns with the updated BuildManager signature, enabling configuration propagation through the account manager initialization flow.

client/ssh/server/user_utils_js.go (1)

1-8: errNotSupported is properly defined in the server package.

The variable is defined in client/ssh/server/command_execution_js.go at line 13, making it accessible to the validateUsername function in user_utils_js.go since both files are in the same server package. No issues found.

client/ssh/config/shutdown_state.go (1)

14-22: Cleanup reuses the SSH manager correctly.

Delegating to RemoveSSHClientConfig with the captured directory/file mirrors the manager API and keeps shutdown tidy.

client/ssh/server/command_execution_js.go (1)

15-36: JS stub correctly flags unsupported command execution.

Returning errNotSupported across the executor entry points cleanly blocks wasm/js builds from invoking unimplemented flows.

client/server/server_test.go (1)

314-314: BuildManager call updated with config dependency.

Supplying the freshly created config satisfies the new signature while keeping the test harness intact.

client/ssh/server/session_handlers_js.go (1)

12-21: JS PTY handler responds with explicit unsupported message.

Writing the stderr notice and exiting with code 1 makes the wasm/js limitation clear without destabilizing the session.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (4)
client/cmd/ssh.go (1)

384-397: Long-form SSH flags never take effect.

--port, --user, --known-hosts, and --identity are registered with fs.Int/fs.String, which return pointers that are immediately discarded. As a result, those long-form flags never update flags.Port, flags.Username, etc., so netbird ssh --port 22022 host still dials the default port.

Apply this diff to bind the aliases correctly:

 	fs.IntVar(&flags.Port, "p", sshserver.DefaultSSHPort, "SSH port")
-	fs.Int("port", sshserver.DefaultSSHPort, "SSH port")
+	fs.IntVar(&flags.Port, "port", sshserver.DefaultSSHPort, "SSH port")
 	fs.StringVar(&flags.Username, "u", "", sshUsernameDesc)
-	fs.String("user", "", sshUsernameDesc)
+	fs.StringVar(&flags.Username, "user", "", sshUsernameDesc)
 	fs.StringVar(&flags.Login, "login", "", sshUsernameDesc+" (alias for --user)")
 	fs.BoolVar(&flags.RequestPTY, "t", false, "Force pseudo-terminal allocation")
-	fs.Bool("tty", false, "Force pseudo-terminal allocation")
+	fs.BoolVar(&flags.RequestPTY, "tty", false, "Force pseudo-terminal allocation")

 	fs.BoolVar(&flags.StrictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking")
 	fs.StringVar(&flags.KnownHostsFile, "o", "", "Path to known_hosts file")
-	fs.String("known-hosts", "", "Path to known_hosts file")
+	fs.StringVar(&flags.KnownHostsFile, "known-hosts", "", "Path to known_hosts file")
 	fs.StringVar(&flags.IdentityFile, "i", "", "Path to SSH private key file")
-	fs.String("identity", "", "Path to SSH private key file")
+	fs.StringVar(&flags.IdentityFile, "identity", "", "Path to SSH private key file")
client/ssh/server/command_execution_windows.go (1)

108-125: Unload the loaded profile before returning.

Line 124 returns after LoadUserProfileW succeeds, but we never call UnloadUserProfileW on profile.hProfile. That leaves the user’s registry hive mounted and leaks handles, which breaks subsequent logons after a few sessions. Please load the procedure once, defer UnloadUserProfileW(userToken, profile.hProfile) on success, and log any unload error without masking the return value.

 	userenv := windows.NewLazySystemDLL("userenv.dll")
 	loadUserProfileW := userenv.NewProc("LoadUserProfileW")
+	unloadUserProfileW := userenv.NewProc("UnloadUserProfileW")
@@
 	if profile.lpProfilePath == nil {
 		return "", fmt.Errorf("LoadUserProfileW returned null profile path")
 	}
 
+	if profile.hProfile != 0 {
+		defer func() {
+			ret, _, unloadErr := unloadUserProfileW.Call(uintptr(userToken), uintptr(profile.hProfile))
+			if ret == 0 {
+				log.Debugf("UnloadUserProfileW failed: %v", unloadErr)
+			}
+		}()
+	}
+
 	profilePath := windows.UTF16PtrToString(profile.lpProfilePath)
 	return profilePath, nil
client/ssh/client/terminal_windows.go (1)

80-108: Restore console state if PTY request fails.

When session.RequestPty on Line 107 fails, we bail out while stdout/stdin remain in VT/raw mode. The user’s console stays broken (no echo) until they restart the terminal. Restore the saved console state before returning and wrap the error for context.

-	return session.RequestPty("xterm-256color", h, w, modes)
+	if err := session.RequestPty("xterm-256color", h, w, modes); err != nil {
+		if restoreErr := c.restoreWindowsConsoleState(); restoreErr != nil {
+			log.Debugf("restore Windows console state: %v", restoreErr)
+		}
+		return fmt.Errorf("request pty: %w", err)
+	}
+
+	return nil
client/ssh/client/terminal_unix.go (1)

26-61: Restore raw TTY on errors before returning.

Line 60 returns c.setupTerminal while the terminal is already in raw mode and the signal goroutine is running. If RequestPty fails, the function exits without restoring the tty, leaving the user’s shell unusable (stty sane required). Move the c.setupTerminal call immediately after term.MakeRaw, restore the tty on error, and only store c.terminalState/spawn the watcher once the PTY is successfully negotiated.

-	state, err := term.MakeRaw(fd)
-	if err != nil {
-		return c.setupNonTerminalMode(ctx, session)
-	}
-
-	c.terminalState = state
-	c.terminalFd = fd
-
-	sigChan := make(chan os.Signal, 1)
-	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
-
-	go func() {
+	state, err := term.MakeRaw(fd)
+	if err != nil {
+		return c.setupNonTerminalMode(ctx, session)
+	}
+
+	if err := c.setupTerminal(session, fd); err != nil {
+		if restoreErr := term.Restore(fd, state); restoreErr != nil {
+			log.Debugf("restore terminal state: %v", restoreErr)
+		}
+		return err
+	}
+
+	c.terminalState = state
+	c.terminalFd = fd
+
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
+
+	go func() {
 		defer signal.Stop(sigChan)
 		select {
 		case <-ctx.Done():
 			if err := term.Restore(fd, state); err != nil {
@@
-	}()
-
-	return c.setupTerminal(session, fd)
+	}()
+
+	return nil
🧹 Nitpick comments (8)
client/ssh/server/command_execution_unix.go (5)

61-76: Consider extracting the timeout constant.

The 500ms timeout is hardcoded. Consider extracting it to a named constant for maintainability.

+const suDetectionTimeout = 500 * time.Millisecond
+
 func (s *Server) detectSuPtySupport(ctx context.Context) bool {
-	ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
+	ctx, cancel := context.WithTimeout(ctx, suDetectionTimeout)
 	defer cancel()

122-131: Consider extracting default terminal type.

The default terminal type "xterm-256color" is hardcoded. Consider extracting it to a named constant.

+const defaultTerminalType = "xterm-256color"
+
 func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool {
 	termType := ptyReq.Term
 	if termType == "" {
-		termType = "xterm-256color"
+		termType = defaultTerminalType
 	}

175-193: Consider extracting default window size constants.

The default terminal dimensions (80 columns × 24 rows) are hardcoded. Consider extracting them to named constants.

+const (
+	defaultTerminalCols = 80
+	defaultTerminalRows = 24
+)
+
 func (s *Server) startPtyCommandWithSize(execCmd *exec.Cmd, ptyReq ssh.Pty) (*os.File, error) {
 	winSize := &pty.Winsize{
 		Cols: uint16(ptyReq.Window.Width),
 		Rows: uint16(ptyReq.Window.Height),
 	}
 	if winSize.Cols == 0 {
-		winSize.Cols = 80
+		winSize.Cols = defaultTerminalCols
 	}
 	if winSize.Rows == 0 {
-		winSize.Rows = 24
+		winSize.Rows = defaultTerminalRows
 	}

211-234: Consider adjusting log levels for expected PTY errors.

The IO copy error handlers log io.EOF and syscall.EIO at Warn level, but these are often expected during normal PTY operation (e.g., when the command exits or the terminal is closed). Consider using Debug level for these expected cases.

 		if _, err := io.Copy(ptmx, session); err != nil {
 			if !errors.Is(err, io.EOF) && !errors.Is(err, syscall.EIO) {
 				logger.Warnf("Pty input copy error: %v", err)
+			} else {
+				logger.Debugf("Pty input copy ended: %v", err)
 			}
 		}

Apply similar changes to the output copy handler.


251-273: Consider extracting termination timeout constant.

The 5-second timeout is hardcoded. Consider extracting it to a named constant for maintainability and configurability.

+const processTerminationTimeout = 5 * time.Second
+
 func (s *Server) handlePtySessionCancellation(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyMgr *ptyManager, done <-chan error) {
 	logger.Debugf("Pty session cancelled, terminating command")
 	if err := ptyMgr.Close(); err != nil {
 		logger.Debugf("Pty close during session cancellation: %v", err)
 	}
 
 	s.killProcessGroup(execCmd)
 
 	select {
 	case err := <-done:
 		if err != nil {
 			logger.Debugf("Pty command terminated after session cancellation with error: %v", err)
 		} else {
 			logger.Debugf("Pty command terminated after session cancellation")
 		}
-	case <-time.After(5 * time.Second):
+	case <-time.After(processTerminationTimeout):
-		logger.Warnf("Pty command did not terminate within 5 seconds after session cancellation")
+		logger.Warnf("Pty command did not terminate within %v after session cancellation", processTerminationTimeout)
 	}
client/cmd/ssh.go (3)

79-81: Clarify deprecated flag handling.

The --identity flag is marked deprecated but is still parsed and passed to the SSH client (lines 441, 498). If the flag is truly deprecated and non-functional, consider removing it from the flag set entirely. If it still works but is discouraged, the deprecation message should clarify what users should use instead.


171-180: Consider unexporting getEnvOrDefault or consolidating with SetFlagsFromEnvVars.

This function duplicates the environment variable lookup logic from client/cmd/root.go's SetFlagsFromEnvVars. Since it's only used within this file, either unexport it (rename to getEnvOrDefault) or refactor to share the common logic.


476-484: Document the username fallback behavior.

The fallback to "root" when SUDO_USER and user.Current() are unavailable is reasonable for many scenarios, but it might cause unexpected behavior on systems where root login is disabled or the user expects their local username. Consider logging a debug message when falling back to "root" to aid troubleshooting.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 79c400a and 25d2675.

📒 Files selected for processing (11)
  • client/cmd/ssh.go (1 hunks)
  • client/ssh/client/terminal_unix.go (1 hunks)
  • client/ssh/client/terminal_windows.go (1 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/command_execution_js.go (1 hunks)
  • client/ssh/server/command_execution_unix.go (1 hunks)
  • client/ssh/server/command_execution_windows.go (1 hunks)
  • client/ssh/server/executor_windows.go (1 hunks)
  • client/ssh/server/server.go (1 hunks)
  • client/ssh/server/server_test.go (1 hunks)
  • client/ssh/server/sftp_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (11)
client/ssh/server/sftp_test.go (2)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (2)
  • Config (143-149)
  • New (153-165)
client/ssh/server/executor_windows.go (1)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • PrivilegeDropper (38-38)
client/ssh/server/command_execution.go (2)
client/ssh/server/server.go (1)
  • Server (108-133)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/client/terminal_windows.go (1)
client/ssh/client/client.go (1)
  • Client (37-44)
client/ssh/server/command_execution_js.go (2)
client/ssh/server/server.go (1)
  • Server (108-133)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (30-30)
  • New (153-165)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/ssh/server/server_test.go (2)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (3)
  • Config (143-149)
  • New (153-165)
  • Server (108-133)
client/ssh/client/terminal_unix.go (1)
client/ssh/client/client.go (1)
  • Client (37-44)
client/ssh/server/server.go (5)
shared/auth/jwt/validator.go (2)
  • Validator (56-63)
  • NewValidator (72-85)
shared/auth/jwt/extractor.go (3)
  • ClaimsExtractor (34-37)
  • NewClaimsExtractor (58-68)
  • WithAudience (43-47)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/detection/detection.go (2)
  • ServerIdentifier (16-16)
  • JWTRequiredMarker (20-20)
version/version.go (1)
  • NetbirdVersion (18-20)
client/ssh/server/command_execution_unix.go (2)
client/ssh/server/server.go (2)
  • New (153-165)
  • Server (108-133)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/command_execution_windows.go (4)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (1)
  • NewPrivilegeDropper (41-43)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/winpty/conpty.go (3)
  • PtyConfig (36-42)
  • UserConfig (45-48)
  • ExecutePtyWithUserToken (59-71)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: release
  • GitHub Check: release_ui
  • GitHub Check: Windows
  • GitHub Check: Linux
  • GitHub Check: Android / Build
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: Client / Unit
  • GitHub Check: Build Cache
  • GitHub Check: Client / Unit
🔇 Additional comments (9)
client/ssh/server/command_execution_unix.go (8)

102-120: LGTM!

The shell command argument construction and environment preparation logic is clean and follows security best practices by filtering session environment variables through acceptEnv.


133-149: LGTM!

PTY handling logic correctly creates the command, handles errors gracefully with appropriate user-facing messages, and delegates to the common PTY execution path.


151-173: LGTM!

The PTY command execution orchestration correctly spawns concurrent goroutines for window resize handling and bidirectional IO, while the main path waits for command completion. The deferred PTY cleanup ensures resource safety.


195-209: LGTM!

Window resize handling correctly listens to resize events and updates PTY size. Debug-level logging for resize errors is appropriate since these are typically non-critical.


236-249: LGTM!

The completion waiting logic correctly handles both normal command termination and session cancellation using a select statement, ensuring graceful shutdown in either case.


275-287: LGTM!

Command completion handling correctly distinguishes between success and failure cases, delegating to appropriate session exit handlers.


289-293: LGTM!

Process group setup correctly uses Setpgid to create a new process group, enabling proper cleanup of the entire process tree.


36-46: Race condition in Close() method.

The Close() method has a race condition. After once.Do() returns, a goroutine calling Close() will acquire RLock and read pm.closeErr. However, once.Do() only guarantees the function won't run multiple times—not that it has completed when it returns to subsequent callers. If another goroutine is currently executing the close logic inside once.Do(), the read of pm.closeErr races with the write at line 40.

Apply this diff to fix the race:

 func (pm *ptyManager) Close() error {
 	pm.once.Do(func() {
 		pm.mu.Lock()
+		defer pm.mu.Unlock()
 		pm.closed = true
 		pm.closeErr = pm.file.Close()
-		pm.mu.Unlock()
 	})
-	pm.mu.RLock()
-	defer pm.mu.RUnlock()
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
 	return pm.closeErr
 }

Alternatively, simplify by removing the RLock after once.Do() since once.Do() already blocks until completion:

 func (pm *ptyManager) Close() error {
+	var err error
 	pm.once.Do(func() {
 		pm.mu.Lock()
 		pm.closed = true
-		pm.closeErr = pm.file.Close()
+		err = pm.file.Close()
+		pm.closeErr = err
 		pm.mu.Unlock()
 	})
-	pm.mu.RLock()
-	defer pm.mu.RUnlock()
-	return pm.closeErr
+	return err
 }

Actually, the simplest fix: sync.Once.Do blocks until the function completes, so subsequent calls will see the updated closeErr. However, reads of pm.closeErr outside of locks are racy. The second diff above captures the error in a local variable to avoid the race.

Likely an incorrect or invalid review comment.

client/cmd/ssh.go (1)

135-136: Verify environment variable handling with custom flag parsing.

SetFlagsFromEnvVars is designed for Cobra's automatic flag parsing, but this command uses DisableFlagParsing: true (line 122) and custom parsing. Confirm that environment variables are correctly applied to SSH flags, or handle them explicitly in validateSSHArgsWithoutFlagParsing.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
client/proto/daemon.proto (1)

641-647: Align SSH forwarding flag names in SetConfigRequest
LoginRequest already exposes enableSSHLocalPortForwarding / enableSSHRemotePortForwarding, but SetConfigRequest drops the “ing”. That inconsistency leaks into every generated client and is a source of bugs once the proto is frozen. Please rename these fields now and regenerate the stubs to keep the public API coherent.

-  optional bool enableSSHLocalPortForward = 31;
-  optional bool enableSSHRemotePortForward = 32;
+  optional bool enableSSHLocalPortForwarding = 31;
+  optional bool enableSSHRemotePortForwarding = 32;
client/server/server.go (1)

1379-1408: Use cfg instead of s.config to avoid nil pointer dereference.

As previously flagged, s.config can be nil when this RPC is called before a profile is active. The cfg variable loaded from disk at line 1351 should be used instead to prevent a panic.

Apply this diff to fix the issue:

 	enableSSHRoot := false
-	if s.config.EnableSSHRoot != nil {
-		enableSSHRoot = *s.config.EnableSSHRoot
+	if cfg.EnableSSHRoot != nil {
+		enableSSHRoot = *cfg.EnableSSHRoot
 	}
 
 	enableSSHSFTP := false
-	if s.config.EnableSSHSFTP != nil {
-		enableSSHSFTP = *s.config.EnableSSHSFTP
+	if cfg.EnableSSHSFTP != nil {
+		enableSSHSFTP = *cfg.EnableSSHSFTP
 	}
 
 	enableSSHLocalPortForwarding := false
-	if s.config.EnableSSHLocalPortForwarding != nil {
-		enableSSHLocalPortForwarding = *s.config.EnableSSHLocalPortForwarding
+	if cfg.EnableSSHLocalPortForwarding != nil {
+		enableSSHLocalPortForwarding = *cfg.EnableSSHLocalPortForwarding
 	}
 
 	enableSSHRemotePortForwarding := false
-	if s.config.EnableSSHRemotePortForwarding != nil {
-		enableSSHRemotePortForwarding = *s.config.EnableSSHRemotePortForwarding
+	if cfg.EnableSSHRemotePortForwarding != nil {
+		enableSSHRemotePortForwarding = *cfg.EnableSSHRemotePortForwarding
 	}
 
 	disableSSHAuth := false
-	if s.config.DisableSSHAuth != nil {
-		disableSSHAuth = *s.config.DisableSSHAuth
+	if cfg.DisableSSHAuth != nil {
+		disableSSHAuth = *cfg.DisableSSHAuth
 	}
 
 	sshJWTCacheTTL := int32(0)
-	if s.config.SSHJWTCacheTTL != nil {
-		sshJWTCacheTTL = int32(*s.config.SSHJWTCacheTTL)
+	if cfg.SSHJWTCacheTTL != nil {
+		sshJWTCacheTTL = int32(*cfg.SSHJWTCacheTTL)
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 25d2675 and 5a78ecb.

⛔ Files ignored due to path filters (1)
  • client/proto/daemon.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (9)
  • client/cmd/status.go (1 hunks)
  • client/internal/engine_ssh.go (1 hunks)
  • client/internal/routemanager/manager.go (1 hunks)
  • client/proto/daemon.proto (9 hunks)
  • client/server/server.go (9 hunks)
  • client/ssh/server/server.go (1 hunks)
  • client/ssh/server/session_handlers.go (1 hunks)
  • client/status/status.go (11 hunks)
  • client/status/status_test.go (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/ssh/server/session_handlers.go
🧰 Additional context used
🧬 Code graph analysis (6)
client/cmd/status.go (1)
client/status/status.go (1)
  • ParseGeneralSummary (342-528)
client/status/status_test.go (2)
client/proto/daemon.pb.go (3)
  • SSHServerState (1941-1947)
  • SSHServerState (1960-1960)
  • SSHServerState (1975-1977)
client/status/status.go (3)
  • SSHServerStateOutput (91-94)
  • SSHSessionOutput (84-89)
  • ParseGeneralSummary (342-528)
client/internal/engine_ssh.go (6)
client/ssh/server/server.go (6)
  • SessionInfo (161-166)
  • New (170-184)
  • JWTConfig (144-149)
  • Config (152-158)
  • InternalSSHPort (34-34)
  • Server (115-142)
client/internal/engine.go (1)
  • Engine (138-211)
client/ssh/config/manager.go (2)
  • New (99-105)
  • PeerSSHInfo (92-96)
client/firewall/manager/protocol.go (1)
  • ProtocolTCP (9-9)
shared/management/proto/management.pb.go (9)
  • SSHConfig (2091-2102)
  • SSHConfig (2117-2117)
  • SSHConfig (2132-2134)
  • JWTConfig (1619-1628)
  • JWTConfig (1643-1643)
  • JWTConfig (1658-1660)
  • RemotePeerConfig (2007-2021)
  • RemotePeerConfig (2036-2036)
  • RemotePeerConfig (2051-2053)
client/ssh/config/shutdown_state.go (1)
  • ShutdownState (4-7)
client/server/server.go (4)
client/proto/daemon.pb.go (30)
  • FullStatus (1994-2008)
  • FullStatus (2021-2021)
  • FullStatus (2036-2038)
  • SSHServerState (1941-1947)
  • SSHServerState (1960-1960)
  • SSHServerState (1975-1977)
  • SSHSessionInfo (1872-1880)
  • SSHSessionInfo (1893-1893)
  • SSHSessionInfo (1908-1910)
  • GetPeerSSHHostKeyRequest (4846-4852)
  • GetPeerSSHHostKeyRequest (4865-4865)
  • GetPeerSSHHostKeyRequest (4880-4882)
  • GetPeerSSHHostKeyResponse (4892-4904)
  • GetPeerSSHHostKeyResponse (4917-4917)
  • GetPeerSSHHostKeyResponse (4932-4934)
  • RequestJWTAuthRequest (4965-4971)
  • RequestJWTAuthRequest (4984-4984)
  • RequestJWTAuthRequest (4999-5001)
  • RequestJWTAuthResponse (5011-5029)
  • RequestJWTAuthResponse (5042-5042)
  • RequestJWTAuthResponse (5057-5059)
  • WaitJWTTokenRequest (5111-5119)
  • WaitJWTTokenRequest (5132-5132)
  • WaitJWTTokenRequest (5147-5149)
  • WaitJWTTokenResponse (5166-5176)
  • WaitJWTTokenResponse (5189-5189)
  • WaitJWTTokenResponse (5204-5206)
  • GetConfigResponse (1097-1132)
  • GetConfigResponse (1145-1145)
  • GetConfigResponse (1160-1162)
client/ssh/server/server.go (2)
  • Server (115-142)
  • New (170-184)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/internal/auth/oauth.go (1)
  • NewOAuthFlow (69-81)
client/status/status.go (1)
client/proto/daemon.pb.go (3)
  • SSHServerState (1941-1947)
  • SSHServerState (1960-1960)
  • SSHServerState (1975-1977)
client/ssh/server/server.go (6)
client/ssh/server/port_forwarding.go (3)
  • SessionKey (16-16)
  • ConnectionKey (19-19)
  • ForwardKey (22-22)
shared/auth/jwt/validator.go (2)
  • Validator (56-63)
  • NewValidator (72-85)
shared/auth/jwt/extractor.go (3)
  • ClaimsExtractor (34-37)
  • NewClaimsExtractor (58-68)
  • WithAudience (43-47)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/ssh/detection/detection.go (2)
  • ServerIdentifier (16-16)
  • JWTRequiredMarker (20-20)
version/version.go (1)
  • NetbirdVersion (18-20)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (24)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Management / Integration (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Signal / Unit (386)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Signal / Unit (amd64)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: release_ui
  • GitHub Check: release
  • GitHub Check: Linux
  • GitHub Check: Windows
  • GitHub Check: Android / Build
🔇 Additional comments (14)
client/internal/routemanager/manager.go (1)

41-41: LGTM! Valid import reorganization.

The import has been appropriately moved to group it with other internal NetBird packages, improving consistency.

client/status/status.go (4)

84-94: LGTM!

The new SSH session and server state output structures are well-defined, follow existing patterns in the file, and properly use omitempty for optional fields.


208-230: LGTM!

The mapSSHServer function correctly handles the nil case and properly maps all proto fields to the output structure.


342-342: LGTM!

The SSH server status rendering logic is well-implemented with proper pluralization, clear session display format, and conditional JWT username display.

Also applies to: 447-480, 505-505, 522-522


826-833: LGTM!

The anonymization logic for SSH sessions correctly handles both host:port and plain IP addresses, and anonymizes sensitive command information.

client/server/server.go (9)

49-50: LGTM!

JWT cache field is properly initialized in the constructor, and the default TTL of 0 (disabled) is a safe default.

Also applies to: 88-88, 108-108


382-392: LGTM!

SSH configuration flags are properly assigned from the request message, with appropriate nil checks for pointer fields.


1094-1124: LGTM!

The getSSHServerState function properly handles nil checks for both connectClient and engine, and correctly maps session information to the proto structure.


1126-1176: LGTM!

The GetPeerSSHHostKey RPC properly validates inputs, handles nil cases, and enriches the response with peer information when available.


1178-1197: LGTM!

The getJWTCacheTTL helper function properly handles nil cases and provides appropriate debug logging for cache TTL configuration.


1199-1262: LGTM!

The RequestJWTAuth RPC correctly implements the JWT authentication flow with efficient cache checking, proper hint handling, and complete response construction.


1264-1305: LGTM!

The WaitJWTToken RPC properly validates the device code, waits for token completion, conditionally caches the token, and cleans up the OAuth flow state.


1307-1312: LGTM!

The isUnixRunningDesktop helper correctly detects desktop environments on Unix systems using standard environment variables.


1664-1664: LGTM!

The SSH host key is properly included in the peer state conversion to proto format.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
client/cmd/ssh.go (1)

388-401: Long-form SSH flags still don't work—critical regression.

Lines 389, 391, 398, and 400 call fs.Int/fs.String without binding the returned pointer to any variable, so --port, --user, --known-hosts, and --identity have no effect. This is the same issue raised in a previous review (marked as addressed), but the fix was not applied. Users running netbird ssh --port 22022 host will silently connect to the default port instead.

Apply this diff to bind the long-form flags correctly:

 func createSSHFlagSet() (*flag.FlagSet, *sshFlags) {
 	defaultConfigPath := getEnvOrDefault("CONFIG", configPath)
 	defaultLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel)
 
 	fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError)
 	fs.SetOutput(nil)
 
 	flags := &sshFlags{}
 
 	fs.IntVar(&flags.Port, "p", sshserver.DefaultSSHPort, "SSH port")
-	fs.Int("port", sshserver.DefaultSSHPort, "SSH port")
+	fs.IntVar(&flags.Port, "port", sshserver.DefaultSSHPort, "SSH port")
 	fs.StringVar(&flags.Username, "u", "", sshUsernameDesc)
-	fs.String("user", "", sshUsernameDesc)
+	fs.StringVar(&flags.Username, "user", "", sshUsernameDesc)
 	fs.StringVar(&flags.Login, "login", "", sshUsernameDesc+" (alias for --user)")
 	fs.BoolVar(&flags.RequestPTY, "t", false, "Force pseudo-terminal allocation")
-	fs.Bool("tty", false, "Force pseudo-terminal allocation")
+	fs.BoolVar(&flags.RequestPTY, "tty", false, "Force pseudo-terminal allocation")
 
 	fs.BoolVar(&flags.StrictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking")
 	fs.StringVar(&flags.KnownHostsFile, "o", "", "Path to known_hosts file")
-	fs.String("known-hosts", "", "Path to known_hosts file")
+	fs.StringVar(&flags.KnownHostsFile, "known-hosts", "", "Path to known_hosts file")
 	fs.StringVar(&flags.IdentityFile, "i", "", "Path to SSH private key file")
-	fs.String("identity", "", "Path to SSH private key file")
+	fs.StringVar(&flags.IdentityFile, "identity", "", "Path to SSH private key file")
 	fs.BoolVar(&flags.SkipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication")
 
 	fs.StringVar(&flags.ConfigPath, "c", defaultConfigPath, "Netbird config file location")
-	fs.String("config", defaultConfigPath, "Netbird config file location")
+	fs.StringVar(&flags.ConfigPath, "config", defaultConfigPath, "Netbird config file location")
 	fs.StringVar(&flags.LogLevel, "l", defaultLogLevel, "sets Netbird log level")
-	fs.String("log-level", defaultLogLevel, "sets Netbird log level")
+	fs.StringVar(&flags.LogLevel, "log-level", defaultLogLevel, "sets Netbird log level")
 
 	return fs, flags
 }
client/ui/client_ui.go (2)

501-507: Keep the settings dialog open until a save actually succeeds.

The deferred s.wSettings.Close() still fires when applySettingsChanges rejects invalid input (e.g., a non-numeric SSH JWT TTL), so the window vanishes and the user loses the chance to fix the mistake—same issue as previously reported. Please close the window only after a successful save and surface the error to the user.

-	iMngURL := strings.TrimSpace(s.iMngURL.Text)
-	defer s.wSettings.Close()
-
-	if s.hasSettingsChanged(iMngURL, port, mtu) {
-		s.applySettingsChanges(iMngURL, port, mtu)
-	}
+	iMngURL := strings.TrimSpace(s.iMngURL.Text)
+
+	if !s.hasSettingsChanged(iMngURL, port, mtu) {
+		s.wSettings.Close()
+		return
+	}
+
+	if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
+		dialog.ShowError(err, s.wSettings)
+		return
+	}
+
+	s.wSettings.Close()

554-567: Bubble up config-build failures instead of swallowing them.

When buildSetConfigRequest fails (again, invalid JWT TTL is an easy repro) we just log and return silently, so the caller never learns why the save didn’t happen. Please have applySettingsChanges return an error and let the caller decide how to notify the user.

-func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) {
-	s.managementURL = iMngURL
-	s.preSharedKey = s.iPreSharedKey.Text
-	s.mtu = uint16(mtu)
+func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error {
 
 	req, err := s.buildSetConfigRequest(iMngURL, port, mtu)
 	if err != nil {
-		log.Errorf("build config request: %v", err)
-		return
+		return err
 	}
 
 	if err := s.sendConfigUpdate(req); err != nil {
-		dialog.ShowError(fmt.Errorf("Failed to set configuration: %v", err), s.wSettings)
+		return fmt.Errorf("failed to set configuration: %w", err)
 	}
+
+	s.managementURL = iMngURL
+	s.preSharedKey = s.iPreSharedKey.Text
+	s.mtu = uint16(mtu)
+
+	return nil
 }
🧹 Nitpick comments (2)
client/cmd/ssh.go (1)

175-184: Consider reusing SetFlagsFromEnvVars logic.

getEnvOrDefault duplicates the WT_/NB_ prefix lookup pattern already implemented in SetFlagsFromEnvVars (client/cmd/root.go lines 197-218). If this helper is only used in a few places, inlining the logic is fine, but if it grows, consider consolidating the environment variable handling to reduce maintenance overhead.

client/proto/daemon.proto (1)

275-285: Field numbering is non-sequential in GetConfigResponse.

Fields 21 (enableSSHRoot), 24 (enableSSHSFTP), 22 (enableSSHLocalPortForwarding), 23 (enableSSHRemotePortForwarding), 25 (disableSSHAuth), 26 (sshJWTCacheTTL) are out of order. While proto3 allows non-sequential field numbers, the unusual ordering suggests these were added incrementally. This is valid but may cause minor confusion during code review or when extending the message.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a78ecb and 9176f8b.

⛔ Files ignored due to path filters (1)
  • client/proto/daemon.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (14)
  • client/cmd/ssh.go (1 hunks)
  • client/cmd/ssh_sftp_windows.go (1 hunks)
  • client/cmd/up.go (3 hunks)
  • client/internal/engine_ssh.go (1 hunks)
  • client/proto/daemon.proto (9 hunks)
  • client/server/jwt_cache.go (1 hunks)
  • client/server/server.go (9 hunks)
  • client/server/setconfig_test.go (5 hunks)
  • client/ssh/client/client.go (1 hunks)
  • client/ssh/client/client_test.go (1 hunks)
  • client/ssh/client/terminal_unix.go (1 hunks)
  • client/ssh/client/terminal_windows.go (1 hunks)
  • client/ssh/server/shell.go (1 hunks)
  • client/ui/client_ui.go (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • client/ssh/server/shell.go
  • client/server/jwt_cache.go
  • client/internal/engine_ssh.go
  • client/ssh/client/terminal_windows.go
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/ssh/client/terminal_unix.go
  • client/ssh/client/client_test.go
  • client/cmd/ssh.go
🧬 Code graph analysis (7)
client/cmd/ssh_sftp_windows.go (1)
client/ssh/server/executor_windows.go (3)
  • ExitCodeValidationFail (27-27)
  • ExitCodeShellExecFail (26-26)
  • ExitCodeSuccess (22-22)
client/ssh/client/client.go (5)
client/ssh/detection/detection.go (3)
  • Timeout (23-23)
  • Dialer (35-37)
  • DetectSSHServerType (59-99)
client/ssh/common.go (4)
  • AddJWTAuth (132-136)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/proto/daemon_grpc.pb.go (1)
  • NewDaemonServiceClient (79-81)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/client/terminal_unix.go (1)
client/ssh/client/client.go (1)
  • Client (37-44)
client/ssh/client/client_test.go (5)
client/ssh/testutil/user_helpers.go (4)
  • CleanupTestUsers (87-93)
  • GetTestUsername (20-45)
  • IsCI (48-59)
  • IsSystemAccount (62-76)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (3)
  • Config (152-158)
  • New (170-184)
  • Server (115-142)
client/ssh/server/test.go (1)
  • StartTestServer (12-45)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • DialOptions (275-281)
  • Client (37-44)
client/ui/client_ui.go (2)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (3)
  • SetConfigRequest (3918-3959)
  • SetConfigRequest (3972-3972)
  • SetConfigRequest (3987-3989)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (31-31)
  • New (170-184)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/client/client.go (3)
  • Dial (284-310)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/server/server.go (5)
client/proto/daemon.pb.go (30)
  • FullStatus (1994-2008)
  • FullStatus (2021-2021)
  • FullStatus (2036-2038)
  • SSHServerState (1941-1947)
  • SSHServerState (1960-1960)
  • SSHServerState (1975-1977)
  • SSHSessionInfo (1872-1880)
  • SSHSessionInfo (1893-1893)
  • SSHSessionInfo (1908-1910)
  • GetPeerSSHHostKeyRequest (4846-4852)
  • GetPeerSSHHostKeyRequest (4865-4865)
  • GetPeerSSHHostKeyRequest (4880-4882)
  • GetPeerSSHHostKeyResponse (4892-4904)
  • GetPeerSSHHostKeyResponse (4917-4917)
  • GetPeerSSHHostKeyResponse (4932-4934)
  • RequestJWTAuthRequest (4965-4971)
  • RequestJWTAuthRequest (4984-4984)
  • RequestJWTAuthRequest (4999-5001)
  • RequestJWTAuthResponse (5011-5029)
  • RequestJWTAuthResponse (5042-5042)
  • RequestJWTAuthResponse (5057-5059)
  • WaitJWTTokenRequest (5111-5119)
  • WaitJWTTokenRequest (5132-5132)
  • WaitJWTTokenRequest (5147-5149)
  • WaitJWTTokenResponse (5166-5176)
  • WaitJWTTokenResponse (5189-5189)
  • WaitJWTTokenResponse (5204-5206)
  • GetConfigResponse (1097-1132)
  • GetConfigResponse (1145-1145)
  • GetConfigResponse (1160-1162)
client/ssh/server/server.go (2)
  • Server (115-142)
  • New (170-184)
client/internal/engine.go (1)
  • Engine (138-211)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/internal/auth/oauth.go (1)
  • NewOAuthFlow (69-81)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Android / Build
  • GitHub Check: iOS / Build
  • GitHub Check: JS / Lint
  • GitHub Check: Client / Unit
  • GitHub Check: Build Cache
  • GitHub Check: release_ui
  • GitHub Check: release
  • GitHub Check: release_ui_darwin
  • GitHub Check: Linux
  • GitHub Check: Windows
  • GitHub Check: Client / Unit
  • GitHub Check: Darwin
  • GitHub Check: Client / Unit

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

♻️ Duplicate comments (5)
client/ssh/client/client.go (1)

283-310: Critical: Daemon address resolved too late for host key verification.

This issue was flagged in a previous review but remains unfixed. createHostKeyCallback is invoked at line 285 before deriving the default daemon address (lines 304-307), so opts.DaemonAddr is empty when the callback closure is created. Consequently, tryDaemonVerification at line 447 will always bail out with "no daemon address" and skip daemon-backed host-key verification, falling back to local known_hosts only. This reproduces the "key not found in NetBird daemon or any known_hosts file" failure reported after toggling SSH in the PR objectives.

Apply this diff to resolve the daemon address before creating the callback:

 func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, error) {
+	daemonAddr := opts.DaemonAddr
+	if daemonAddr == "" {
+		daemonAddr = getDefaultDaemonAddr()
+	}
+	opts.DaemonAddr = daemonAddr
+
 	hostKeyCallback, err := createHostKeyCallback(opts)
 	if err != nil {
 		return nil, fmt.Errorf("create host key callback: %w", err)
 	}
 
 	config := &ssh.ClientConfig{
 		User:            user,
 		Timeout:         30 * time.Second,
 		HostKeyCallback: hostKeyCallback,
 	}
 
 	if opts.IdentityFile != "" {
 		authMethod, err := createSSHKeyAuth(opts.IdentityFile)
 		if err != nil {
 			return nil, fmt.Errorf("create SSH key auth: %w", err)
 		}
 		config.Auth = append(config.Auth, authMethod)
 	}
 
-	daemonAddr := opts.DaemonAddr
-	if daemonAddr == "" {
-		daemonAddr = getDefaultDaemonAddr()
-	}
-
 	return dialWithJWT(ctx, "tcp", addr, config, daemonAddr, opts.SkipCachedToken)
 }
client/ssh/proxy/proxy.go (2)

95-98: Keep the proxy host key stable across runs.

Generating a new host key on every proxy invocation causes native SSH clients to detect a host key change and fail with "REMOTE HOST IDENTIFICATION HAS CHANGED!" errors. This issue was previously flagged and remains unresolved.

As suggested in the prior review, persist a single proxy host key (e.g., in NetBird's config directory) or generate a deterministic key so native clients see a consistent host identity across sessions.

Also applies to: 174-186


147-152: Use session.RawCommand() to preserve command quoting and signal success explicitly.

While exit code handling has been partially added (line 150 calls handleProxyExitCode), the command execution still has issues:

  1. strings.Join(session.Command(), " ") loses original shell quoting, breaking commands with quotes or special characters.
  2. Successful command completion (nil error) doesn't explicitly call session.Exit(0), so the client may not receive the correct exit status.

Apply the previously suggested fix using session.RawCommand():

-	if len(session.Command()) > 0 {
-		if err := serverSession.Run(strings.Join(session.Command(), " ")); err != nil {
+	if rawCmd := session.RawCommand(); rawCmd != "" {
+		if err := serverSession.Run(rawCmd); err != nil {
 			log.Debugf("run command: %v", err)
 			p.handleProxyExitCode(session, err)
+			return
 		}
+		_ = session.Exit(0)
 		return
 	}

Similarly, ensure shell execution signals success:

 	if err = serverSession.Shell(); err != nil {
 		log.Debugf("start shell: %v", err)
 		return
 	}
 	if err := serverSession.Wait(); err != nil {
 		log.Debugf("session wait: %v", err)
 		p.handleProxyExitCode(session, err)
+		return
 	}
+	_ = session.Exit(0)
client/ssh/server/winpty/conpty_test.go (1)

28-32: Update cmd.exe expectation to use /C.

This test is still asserting the PowerShell-only -Command flag for cmd.exe. Native cmd needs /C, otherwise the launched process terminates with a syntax error. Please switch the expectation (and the implementation) to /C so we don’t regress on Windows command execution.

Apply this diff:

 		{
 			name:     "CMD with command",
 			shell:    "cmd.exe",
 			command:  "dir",
-			expected: []string{"cmd.exe", "-Command", "dir"},
+			expected: []string{"cmd.exe", "/C", "dir"},
client/ssh/server/command_execution_windows.go (1)

106-123: Unload user profiles after LoadUserProfileW.

LoadUserProfileW mounts the user’s registry hive and hands back a profile handle. We never call UnloadUserProfileW, so every SSH session leaks a hive and prevents clean logoff. Please unload the profile before returning.

Apply this diff:

 	userenv := windows.NewLazySystemDLL("userenv.dll")
 	loadUserProfileW := userenv.NewProc("LoadUserProfileW")
+	unloadUserProfileW := userenv.NewProc("UnloadUserProfileW")
@@
 	if profile.lpProfilePath == nil {
 		return "", fmt.Errorf("LoadUserProfileW returned null profile path")
 	}
 
+	if profile.hProfile != 0 {
+		defer func() {
+			if ret, _, unloadErr := unloadUserProfileW.Call(uintptr(userToken), uintptr(profile.hProfile)); ret == 0 {
+				log.Debugf("UnloadUserProfileW failed: %v", unloadErr)
+			}
+		}()
+	}
+
 	profilePath := windows.UTF16PtrToString(profile.lpProfilePath)
 	return profilePath, nil
🧹 Nitpick comments (2)
client/ui/client_ui.go (1)

645-665: Consider adding user feedback for reconnection.

The async reconnection logic (lines 645-665) silently handles errors with logging only. If reconnection fails (e.g., daemon unresponsive), the user won't know whether the new settings were applied. Consider showing a notification or status message to inform the user of reconnection success or failure.

client/ssh/client/client.go (1)

165-177: Consider extracting the termination timeout constant.

Both ExecuteCommandWithIO and ExecuteCommandWithPTY use a hardcoded 100ms timeout (lines 171 and 210) after sending SIGTERM on context cancellation. Extracting this as a named constant would clarify its purpose and make it easier to tune if needed.

Example:

+const (
+	gracefulTerminationTimeout = 100 * time.Millisecond
+)
+
 // ExecuteCommandWithIO executes a command with interactive I/O connected to local terminal
 func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error {
 	// ...
 	select {
 	case <-ctx.Done():
 		_ = session.Signal(ssh.SIGTERM)
 		select {
 		case <-done:
 			return ctx.Err()
-		case <-time.After(100 * time.Millisecond):
+		case <-time.After(gracefulTerminationTimeout):
 			return ctx.Err()
 		}
 	case err := <-done:
 		return c.handleCommandError(err)
 	}
 }

Apply the same change to line 210 in ExecuteCommandWithPTY.

Also applies to: 204-216

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9176f8b and 619e35f.

📒 Files selected for processing (15)
  • client/cmd/ssh.go (1 hunks)
  • client/ssh/client/client.go (1 hunks)
  • client/ssh/proxy/proxy.go (1 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/command_execution_js.go (1 hunks)
  • client/ssh/server/command_execution_unix.go (1 hunks)
  • client/ssh/server/command_execution_windows.go (1 hunks)
  • client/ssh/server/executor_windows.go (1 hunks)
  • client/ssh/server/server_test.go (1 hunks)
  • client/ssh/server/sftp_windows.go (1 hunks)
  • client/ssh/server/userswitching_unix.go (1 hunks)
  • client/ssh/server/userswitching_windows.go (1 hunks)
  • client/ssh/server/winpty/conpty.go (1 hunks)
  • client/ssh/server/winpty/conpty_test.go (1 hunks)
  • client/ui/client_ui.go (6 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • client/ssh/server/command_execution_js.go
  • client/cmd/ssh.go
  • client/ssh/server/userswitching_unix.go
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/ssh/server/winpty/conpty.go
  • client/ssh/server/sftp_windows.go
  • client/ssh/proxy/proxy.go
  • client/ssh/server/userswitching_windows.go
  • client/ssh/server/executor_windows.go
  • client/ssh/server/command_execution_windows.go
  • client/ssh/server/command_execution.go
  • client/ssh/server/command_execution_unix.go
🧬 Code graph analysis (10)
client/ssh/server/server_test.go (2)
client/ssh/ssh.go (2)
  • GeneratePrivateKey (36-59)
  • ED25519 (24-24)
client/ssh/server/server.go (3)
  • Config (152-158)
  • New (170-184)
  • Server (115-142)
client/ui/client_ui.go (3)
client/server/server.go (1)
  • New (99-110)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (3)
  • SetConfigRequest (3918-3959)
  • SetConfigRequest (3972-3972)
  • SetConfigRequest (3987-3989)
client/ssh/server/sftp_windows.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/client/client.go (5)
client/ssh/detection/detection.go (3)
  • Timeout (23-23)
  • Dialer (35-37)
  • DetectSSHServerType (59-99)
client/ssh/common.go (4)
  • AddJWTAuth (132-136)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/proto/daemon_grpc.pb.go (1)
  • NewDaemonServiceClient (79-81)
client/ssh/proxy/proxy.go (1)
  • New (45-59)
client/ssh/proxy/proxy.go (5)
client/proto/daemon_grpc.pb.go (2)
  • DaemonServiceClient (20-73)
  • NewDaemonServiceClient (79-81)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/ssh/common.go (3)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/ssh/detection/detection.go (1)
  • ProxyIdentifier (18-18)
version/version.go (1)
  • NetbirdVersion (18-20)
client/ssh/server/userswitching_windows.go (3)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/executor_windows.go (2)
  • WindowsExecutorConfig (30-41)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (1)
  • NewPrivilegeDropper (41-43)
client/ssh/server/executor_windows.go (1)
client/ssh/server/server.go (2)
  • New (170-184)
  • Server (115-142)
client/ssh/server/command_execution_windows.go (3)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/winpty/conpty.go (3)
  • PtyConfig (36-42)
  • UserConfig (45-48)
  • ExecutePtyWithUserToken (59-71)
client/ssh/server/command_execution.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/command_execution_unix.go (2)
client/ssh/server/server.go (2)
  • New (170-184)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
🔇 Additional comments (28)
client/ui/client_ui.go (5)

478-511: LGTM! Error handling properly addressed.

The refactored saveSettings method correctly addresses the past review concern about keeping the dialog open on validation failures. Each error path (lines 491, 497, 505) now shows a dialog and returns early, only closing the window after successful save.


441-446: LGTM! SSH UI elements properly initialized.

The SSH-related UI controls (checkboxes for server features and entry for JWT cache TTL) are properly initialized with descriptive labels.


670-690: LGTM! Clean tabbed interface refactor.

The settings form has been successfully refactored from a single form to a multi-tab layout (Connection, Network, SSH), improving organization and scalability. Save/Cancel button wiring is correct.


1252-1309: LGTM! Defensive nil checks for SSH config fields.

The nil checks for SSH configuration fields (lines 1252-1269 and 1291-1308) ensure safe handling when fields are not present. While the pattern is repetitive, it's correct and maintainable.


1379-1386: LGTM! SSH field mapping is correct.

The SSH configuration fields are properly mapped from the proto response to the config struct, including appropriate type conversion for the JWT cache TTL (int32 to int).

client/ssh/client/client.go (1)

505-536: LGTM: Local listener properly closed on context cancellation.

The previous review concern about the listener not being closed when the context ends has been addressed. Line 532 explicitly closes localListener after <-ctx.Done(), and line 514 includes an errors.Is(err, net.ErrClosed) guard in the deferred close to handle the case where the listener has already been closed. This prevents both the goroutine leak and the hanging port binding.

client/ssh/proxy/proxy.go (1)

188-240: LGTM: Helper implementations are well-structured.

The stdioConn implementation correctly wraps stdin/stdout as a net.Conn with proper synchronization. The SFTP bridge properly handles bidirectional copying with context cancellation and waits for both goroutines. Port forwarding handlers appropriately reject forwarding requests (consistent with proxy-only mode), and dialBackend correctly configures JWT authentication with appropriate timeouts and host key verification.

Also applies to: 242-244, 246-352, 354-383

client/ssh/server/userswitching_windows.go (4)

18-87: LGTM - Comprehensive Windows username validation

The username validation implementation correctly enforces SAM Account Name rules including length limits (20 chars), invalid character checks, control character rejection, and format validation. The extraction of username from domain formats before validation is appropriate.


141-157: LGTM - parseUsername correctly handles Windows domain formats

The function appropriately handles both DOMAIN\username and username@domain formats, defaulting to "." for local users.


159-248: LGTM - Privilege management follows Windows API patterns correctly

The double-call pattern in hasPrivilege (first with null buffer to get size, then with allocated buffer) and the adjust-then-verify pattern in enablePrivilege are standard and correct for Windows privilege management APIs.


91-100: Cleanup callback is properly invoked—no issues found

The cleanup callback returned by createExecutorCommand is correctly deferred and invoked in both code paths:

  • Non-PTY path (line 46): cleanup is passed to executeCommand, which defers it at line 97. It executes after the process completes.
  • PTY path (line 52): cleanup is deferred directly in handleCommand. It executes after executeCommandWithPty completes.

Both defer statements execute after execCmd.Start() (line 120), ensuring the token handle remains open during process execution and is properly closed afterward. There are no handle leaks.

client/ssh/server/executor_windows.go (3)

64-92: LGTM - Command creation with proper validation

The function validates required fields (username, shell) before proceeding and correctly delegates to CreateWindowsProcessAsUser for token-based process creation.


181-455: LGTM - S4U authentication implementation follows Windows patterns

The S4U (Service for User) logon implementation correctly:

  • Separates domain and local user paths (Kerberos vs MSV1_0)
  • Uses appropriate authentication packages
  • Translates SAM names to UPN format for domain users
  • Constructs logon structures with proper buffer management (aside from the issue flagged separately)
  • Follows the same trust-based approach as OpenSSH for Windows

519-565: LGTM - Token lifecycle management pattern is correct

The token management pattern is sound:

  1. CreateWindowsProcessAsUser creates an impersonation token, defers its closure, duplicates it to a primary token via createProcessWithToken, and returns the primary token
  2. createProcessWithToken duplicates the source token to create an independent primary token handle stored in cmd.SysProcAttr.Token
  3. The primary token is returned to the caller with clear documentation (lines 520, 542) that the caller must close it after starting the process

The impersonation token's defer-close at lines 527-531 is safe because DuplicateTokenEx creates an independent handle. Callers in both userswitching_windows.go and sftp_windows.go properly close the returned primary token after process startup.

client/ssh/server/sftp_windows.go (2)

17-53: LGTM - Token lifecycle correctly managed

The token handling pattern is sound and contradicts the concern in past review comments:

  1. Line 35: Creates impersonation token
  2. Lines 40-44: Defers closing the impersonation token (safe to close after duplication)
  3. Line 46: createProcessWithToken calls DuplicateTokenEx, creating an independent primary token handle
  4. Line 52: Returns the primary token to the caller

The impersonation token's defer-close is correct because DuplicateTokenEx produces a new, independent handle. The primary token remains valid and is properly closed by executeSftpCommand after the process starts.


55-82: LGTM - Process execution with proper resource cleanup

The function correctly:

  • Defers closing the primary token after process completion (lines 57-61)
  • Wires SSH session I/O to the SFTP command (lines 63-65)
  • Starts and waits for the process (lines 67-79)
  • Treats ExitError as non-fatal (lines 72-76), appropriate for SFTP client-initiated disconnects

The token is closed after Wait() returns, ensuring the handle remains valid throughout the process lifetime.

client/ssh/server/command_execution_unix.go (12)

23-34: LGTM! Thread-safe PTY lifecycle management.

The ptyManager uses appropriate synchronization primitives (sync.Once for idempotent close, sync.RWMutex for concurrent access) to safely manage the PTY file lifecycle across multiple goroutines.


36-46: LGTM! Idempotent close with proper synchronization.

The use of sync.Once ensures the file is closed exactly once, even under concurrent calls, and the cached closeErr is safely returned to all callers.


48-59: LGTM! Setsize is thread-safe; File() relies on controlled lifecycle.

Setsize correctly uses read-locking and validates the closed state. File() returns the internal file without locking, which is safe given that runPtyCommand blocks in waitForPtyCompletion before closing the PTY (line 164), ensuring the file remains valid during I/O operations.


61-76: LGTM! Reasonable heuristic for su --pty detection.

The method uses a short timeout and falls back gracefully when su --help fails, which is appropriate since not all su implementations support --help.


102-120: LGTM! Clean environment preparation helpers.

Both methods provide clear, focused logic for building shell command arguments and preparing the command environment with proper filtering via acceptEnv.


122-149: LGTM! Clean PTY command setup and error handling.

Both methods properly initialize PTY commands with sensible defaults (TERM fallback) and handle errors gracefully by writing user-friendly messages to stderr before exiting.


151-193: LGTM! Well-structured PTY lifecycle orchestration.

runPtyCommand properly coordinates the PTY lifecycle with deferred cleanup, goroutine-based window resize handling, and I/O management. startPtyCommandWithSize provides sensible fallback dimensions (80×24).


195-209: LGTM! Proper window resize handling with graceful error handling.

The method correctly uses select to handle both session cancellation and channel closure, and appropriately logs resize errors without failing the session.


211-234: LGTM! Standard bidirectional PTY I/O with appropriate error filtering.

The two goroutines handle I/O independently and correctly filter io.EOF and syscall.EIO (both expected when a PTY is closed), while warning on unexpected errors. The output goroutine's deferred session.Close() ensures proper cleanup.


236-287: LGTM! Robust command completion handling with proper cancellation.

The select correctly discriminates between normal completion and session cancellation. The cancellation path properly closes the PTY first (to unblock I/O goroutines) before killing the process group, and uses a reasonable 5-second timeout. Exit code 130 is standard for SIGINT/SIGTERM.


289-293: LGTM! Correct process group setup for kill propagation.

Setting Setpgid: true ensures the command and all its children are in a new process group, which allows killProcessGroup to terminate the entire tree.


295-329: LGTM! Improved graceful shutdown with polling.

The implementation now correctly polls for process exit (every 50ms using kill(pgid, 0)) rather than unconditionally blocking, and only sends SIGKILL after the 500ms grace period if the process group is still alive. This addresses the previous review concern about always waiting even when processes exit quickly.

@mlsmaycon
Copy link
Collaborator

mlsmaycon commented Nov 13, 2025

Some comments are outside the diff and can’t be posted inline due to platform limitations.

@coderabbitai let's not review code outside of the PR context

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 13, 2025

Tip

For best results, initiate chat on the files or code changes.

@mlsmaycon Understood! I'll focus my reviews exclusively on the code changes within the PR diff. I won't review or comment on code outside the PR context. Thank you for the clarification.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
client/ssh/server/command_execution.go (1)

60-93: UsedFallback logic still attempts unnecessary su command.

Line 77 checks privilegeResult.UsedFallback to fallback to executor, but createSuCommand is already called at line 76 before this check. According to the PrivilegeCheckResult documentation, "When UsedFallback is true, all privilege checks have already been performed and no additional privilege dropping or root checks are needed."

The code should check UsedFallback (or RequiresUserSwitching) before attempting createSuCommand to avoid unnecessary work and align with the semantic intent.

Apply this diff to check upfront:

-	// Try su first for system integration (PAM/audit) when privileged
-	cmd, err := s.createSuCommand(session, localUser, hasPty)
-	if err != nil || privilegeResult.UsedFallback {
-		log.Debugf("su command failed, falling back to executor: %v", err)
+	var cmd *exec.Cmd
+	var err error
+	
+	// When no user switching is needed (UsedFallback), go directly to executor
+	if privilegeResult.UsedFallback {
+		log.Debugf("Using executor directly (no user switching needed)")
 		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
 		if err != nil {
 			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
 		}
 		cmd.Env = s.prepareCommandEnv(localUser, session)
 		return cmd, cleanup, nil
 	}
-
-	if err != nil {
-		return nil, nil, fmt.Errorf("create command with privileges: %w", err)
+	
+	// Try su first for system integration (PAM/audit) when privileged
+	cmd, err = s.createSuCommand(session, localUser, hasPty)
+	if err != nil {
+		log.Debugf("su command failed, falling back to executor: %v", err)
+		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
+		if err != nil {
+			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
+		}
+		cmd.Env = s.prepareCommandEnv(localUser, session)
+		return cmd, cleanup, nil
 	}
 
 	cmd.Env = s.prepareCommandEnv(localUser, session)
 	return cmd, func() {}, nil
client/ssh/proxy/proxy.go (2)

104-108: Regenerating host keys breaks native OpenSSH clients.

A fresh ED25519 key is generated on every proxy invocation. Native OpenSSH/SFTP clients record this key in known_hosts, but on the next connection a different key is presented, triggering "REMOTE HOST IDENTIFICATION HAS CHANGED!" errors. Please persist a single proxy host key (e.g., cache it under NetBird's data directory) to provide a consistent identity across sessions.


156-172: Command quoting still broken despite exit code fix.

Exit status propagation has been improved with handleProxyExitCode, but the command quoting issue remains. strings.Join(session.Command(), " ") at line 157 drops original quoting, so commands like ssh host 'echo "hi there"' are mangled. Use session.RawCommand() to preserve the exact command string the client sent.

Apply this diff:

-	if len(session.Command()) > 0 {
-		if err := serverSession.Run(strings.Join(session.Command(), " ")); err != nil {
+	if rawCmd := session.RawCommand(); rawCmd != "" {
+		if err := serverSession.Run(rawCmd); err != nil {
 			log.Debugf("run command: %v", err)
 			p.handleProxyExitCode(session, err)
 		}
 		return
 	}
client/ssh/server/executor_windows.go (2)

320-357: Domain S4U UPN buffer sizing fix looks correct

The new prepareDomainS4ULogon implementation now copies the UPN into a [1025]uint16 buffer, matching the maxUPNLen+1 sizing used for TranslateNameW, so the previous risk of overrunning a [512]uint16 buffer is resolved. The unicodeString lengths also correctly use (len(upnUtf16)-1)*2 for Length and len(upnUtf16)*2 for MaximumLength, which matches UNICODE_STRING semantics for a UTF-16 UPN payload.

No further changes needed here from a safety perspective.


457-539: Token lifetime handling now separates impersonation vs. primary token; ensure all call sites close the primary token

The refactored flow:

  • CreateWindowsProcessAsUser obtains an S4U impersonation token via createToken, defers CloseHandle(token), and then
  • createProcessWithToken duplicates it to a primary token, attaches it to cmd.SysProcAttr.Token, and returns cmd plus the primaryToken.

This addresses the earlier leak of the duplicated primary token: the impersonation token is closed internally, and the returned windows.Token is the primary token that must be closed by the caller after the child process has been started/finished.

Two follow‑ups to keep this robust:

  • Make sure every call site that receives the windows.Token from CreateWindowsExecutorCommand / CreateWindowsProcessAsUser does a defer windows.CloseHandle(windows.Handle(primaryToken)) (or equivalent) after creating the command and before calling Start/Wait.
  • To reduce the chance of future leaks, you might consider a tiny helper wrapper (e.g., a struct that owns cmd + token and exposes Run()/Wait() while closing the token internally once done), so callers can’t forget the cleanup.

You can quickly verify that all call sites are closing the returned token with something like:

#!/bin/bash
# Search for Windows executor usage and inspect token handling around it.
rg -n 'CreateWindowsExecutorCommand\(' -C4
rg -n 'CreateWindowsProcessAsUser\(' -C4

Also applies to: 541-565

🧹 Nitpick comments (7)
client/ssh/server/executor_windows.go (1)

181-207: Consider converting NTSTATUS error values to Win32 error codes for improved error diagnostics

The S4U authentication path is well-structured and functionally correct. However, LsaLogonUser and other LSA APIs return NTSTATUS values that can be mapped to familiar Win32 ERROR_* codes for easier logging, searching, and display for operators and support teams.

Currently, error logging at lines 216–261 (initializeLsaConnection, cleanupLsaConnection, lookupAuthenticationPackage) and 408–455 (performS4ULogon) outputs raw NTSTATUS hex values (e.g., 0x%x). When logging or returning user-facing error messages, call LsaNtStatusToWinError or RtlNtStatusToDosError to produce Win32 error codes, while preserving NTSTATUS/SubStatus for control-flow logic when needed.

client/ui/client_ui.go (6)

56-59: TTL max constant is reasonable; consider centralizing limit

The 24h maxSSHJWTCacheTTL cap is sensible and prevents pathological cache durations. If other layers (CLI flags, daemon config) also impose TTL limits, consider sharing a single constant to avoid future drift in allowed ranges.


457-477: Connection tab refactor is clean; optional UX tweak

Splitting out getConnectionForm and showing the active profile name is straightforward and improves structure. If you want to avoid a blank “Profile” row on errors, you could populate it with a fallback like “(unknown)” instead of an empty label, but this is purely cosmetic.


479-512: Save flow and PSK validation behavior

The new saveSettings flow correctly:

  • Checks daemon feature flags,
  • Validates the pre-shared key,
  • Parses numeric fields,
  • Keeps the dialog open and surfaces errors instead of closing on failure.

validateSettings’s use of censoredPreSharedKey makes sense only if some code path ever sets iPreSharedKey.Text to that sentinel. In this file, the entry is always populated with the real key or empty text, so the sentinel-based special casing may now be redundant.

Apply either:

  • Wire censoredPreSharedKey into the UI when you want to represent a stored-but-hidden PSK, or
  • Drop the sentinel checks and treat empty string as “no PSK / unchanged” to simplify the flow.

Also applies to: 514-521


523-545: Port and MTU validation look good; minor robustness tweak

The interface port is now range-checked (1–65535) and MTU is validated against iface.MinMTU/iface.MaxMTU, which addresses earlier validation gaps.

You might optionally TrimSpace on the port text (as you already do for MTU) so values like " 51820 " are accepted without confusing the user.


547-560: Baseline vs change detection after successful/failed saves

The combination of hasSettingsChanged, hasSSHChanges, and applySettingsChanges works, but the baseline state updates are asymmetric:

  • applySettingsChanges updates only managementURL, preSharedKey, and mtu before building/sending the request, and never updates the other tracked fields (RosenpassPermissive, interface name/port, network flags, SSH flags, sshJWTCacheTTL).
  • If buildSetConfigRequest or sendConfigUpdate fails, the few fields you do overwrite no longer match the actual daemon config, so subsequent saves may treat them as “unchanged” even though the daemon still has the old values.
  • After a successful save, the non-updated baseline fields mean hasSettingsChanged/hasSSHChanges can continue to report changes and trigger redundant SetConfig + reconnect cycles.

This isn’t breaking, but it’s subtle and could cause unnecessary reconnects or misleading state in edge error cases.

Consider updating all tracked baseline fields only after sendConfigUpdate succeeds (or by re-calling getSrvConfig on success), e.g.:

 func (s *serviceClient) applySettingsChanges(iMngURL string, port, mtu int64) error {
-    s.managementURL = iMngURL
-    s.preSharedKey = s.iPreSharedKey.Text
-    s.mtu = uint16(mtu)
-
     req, err := s.buildSetConfigRequest(iMngURL, port, mtu)
     if err != nil {
         return fmt.Errorf("build config request: %w", err)
     }

     if err := s.sendConfigUpdate(req); err != nil {
         return fmt.Errorf("set configuration: %w", err)
     }
+
+    // Only update baselines after a successful apply.
+    s.managementURL = iMngURL
+    s.preSharedKey = s.iPreSharedKey.Text
+    s.mtu = uint16(mtu)
+    s.RosenpassPermissive = s.sRosenpassPermissive.Checked
+    s.interfaceName = s.iInterfaceName.Text
+    s.interfacePort = int(port)
+    s.networkMonitor = s.sNetworkMonitor.Checked
+    s.disableDNS = s.sDisableDNS.Checked
+    s.disableClientRoutes = s.sDisableClientRoutes.Checked
+    s.disableServerRoutes = s.sDisableServerRoutes.Checked
+    s.blockLANAccess = s.sBlockLANAccess.Checked
+    s.enableSSHRoot = s.sEnableSSHRoot.Checked
+    s.enableSSHSFTP = s.sEnableSSHSFTP.Checked
+    s.enableSSHLocalPortForward = s.sEnableSSHLocalPortForward.Checked
+    s.enableSSHRemotePortForward = s.sEnableSSHRemotePortForward.Checked
+    s.disableSSHAuth = s.sDisableSSHAuth.Checked
+    if text := strings.TrimSpace(s.iSSHJWTCacheTTL.Text); text != "" {
+        if val, err := strconv.Atoi(text); err == nil {
+            s.sshJWTCacheTTL = val
+        }
+    }
+
     return nil
 }

(Adjust fields as needed, or alternatively re-populate everything via getSrvConfig on success.)

Also applies to: 562-577, 721-737


1258-1275: SSH fields and TTL are correctly round-tripped in getSrvConfig

getSrvConfig now:

  • Copies SSH-related fields and TTL from profilemanager.Config into the internal baseline booleans/int.
  • When advanced settings are shown, it sets the SSH checkboxes and TTL entry text from the same config.

This keeps the UI in sync with daemon config on window open. Given protoConfigToConfig always assigns SSHJWTCacheTTL, the nil checks around TTL are slightly defensive, but harmless.

Also applies to: 1297-1314

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 619e35f and f7a37ed.

📒 Files selected for processing (8)
  • client/cmd/ssh.go (1 hunks)
  • client/ssh/client/client.go (1 hunks)
  • client/ssh/client/terminal_unix.go (1 hunks)
  • client/ssh/proxy/proxy.go (1 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/command_execution_unix.go (1 hunks)
  • client/ssh/server/executor_windows.go (1 hunks)
  • client/ui/client_ui.go (7 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/ssh/client/terminal_unix.go
  • client/ssh/server/executor_windows.go
  • client/cmd/ssh.go
  • client/ssh/proxy/proxy.go
  • client/ssh/server/command_execution.go
  • client/ssh/server/command_execution_unix.go
🧬 Code graph analysis (7)
client/ssh/client/client.go (5)
client/ssh/detection/detection.go (3)
  • Timeout (23-23)
  • Dialer (35-37)
  • DetectSSHServerType (59-99)
client/ssh/common.go (4)
  • AddJWTAuth (132-136)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/proto/daemon_grpc.pb.go (1)
  • NewDaemonServiceClient (79-81)
client/ssh/proxy/proxy.go (1)
  • New (46-61)
client/ssh/client/terminal_unix.go (1)
client/ssh/client/client.go (1)
  • Client (37-44)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (31-31)
  • New (170-184)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (46-61)
client/ssh/client/client.go (3)
  • Dial (284-311)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/ssh/proxy/proxy.go (5)
client/proto/daemon_grpc.pb.go (1)
  • DaemonServiceClient (20-73)
client/internal/profilemanager/profilemanager.go (1)
  • GetLoginHint (137-152)
client/ssh/common.go (3)
  • RequestJWTToken (71-107)
  • NewDaemonHostKeyVerifier (43-47)
  • CreateHostKeyCallback (140-159)
client/ssh/detection/detection.go (1)
  • ProxyIdentifier (18-18)
version/version.go (1)
  • NetbirdVersion (18-20)
client/ssh/server/command_execution.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ui/client_ui.go (2)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (3)
  • SetConfigRequest (3918-3959)
  • SetConfigRequest (3972-3972)
  • SetConfigRequest (3987-3989)
client/ssh/server/command_execution_unix.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
🔇 Additional comments (40)
client/ssh/client/terminal_unix.go (3)

17-68: Previous issues successfully resolved.

Both the critical TTY restoration issue and the major fd selection issue from previous reviews have been addressed:

  • Line 24 correctly uses stdin fd for raw mode and PTY negotiation
  • Lines 31-36 properly restore terminal state before returning on setupTerminal error
  • Signal handling goroutine is started only after successful PTY setup

The terminal lifecycle management is now correct and will not leave users with an unusable shell.


70-77: LGTM: Platform-appropriate no-ops.

Both setupNonTerminalMode and restoreWindowsConsoleState correctly implement no-ops for Unix systems.


79-127: LGTM: Standard PTY setup.

The terminal setup correctly:

  • Retrieves terminal dimensions
  • Defines standard terminal modes (control characters, speeds)
  • Defaults to xterm-256color when TERM is unset
  • Requests PTY with appropriate parameters
client/ssh/server/command_execution_unix.go (8)

23-59: LGTM: Thread-safe PTY lifecycle management.

The ptyManager correctly implements thread-safe operations:

  • sync.Once ensures Close is idempotent
  • RWMutex protects concurrent access to state
  • Setsize guards against operations on closed PTY

61-76: LGTM: Runtime detection of su capabilities.

The detection logic appropriately:

  • Uses a 500ms timeout to prevent hanging
  • Runs su --help to query capabilities
  • Returns false on error (safe default)

78-100: Previous issue resolved: su --pty validation.

Line 91 correctly checks s.suSupportsPty before adding the --pty flag, addressing the previous review concern. The command construction properly handles privilege switching via su with appropriate flags.


102-120: LGTM: Environment and command preparation helpers.

Both helper functions correctly:

  • getShellCommandArgs: Constructs appropriate shell invocation
  • prepareCommandEnv: Composes environment from user context, SSH variables, and filtered session environment

122-193: LGTM: Well-structured PTY execution pipeline.

The PTY lifecycle management is cleanly separated:

  • Environment setup (TERM variable)
  • PTY creation with sensible 80x24 defaults
  • Deferred cleanup with ptyManager
  • Proper error propagation and user messaging

195-234: LGTM: Robust PTY IO and resize handling.

Both handlers correctly:

  • handlePtyWindowResize: Responds to window changes and context cancellation
  • handlePtyIO: Bidirectional IO in separate goroutines with proper EOF/EIO handling

The error filtering (EOF, EIO) is appropriate for PTY lifecycle.


236-287: LGTM: Comprehensive completion and cancellation handling.

The completion flow correctly:

  • Waits for command completion or context cancellation
  • On cancellation: closes PTY, kills process group, enforces 5s timeout, exits with 130
  • On completion: propagates exit codes appropriately

289-329: Previous issue resolved: SIGTERM grace period with polling.

The killProcessGroup function now correctly implements the polling approach from the previous review discussion:

  • Sends SIGTERM first
  • Polls process existence every 50ms using kill(pgid, 0)
  • Returns immediately if process exits
  • Only sends SIGKILL after 500ms grace period
  • No unnecessary delays when processes exit quickly

This addresses the previous concern about immediate SIGKILL and ensures graceful shutdown.

client/ssh/server/command_execution.go (5)

15-58: LGTM: Clear command orchestration.

The handleCommand method appropriately:

  • Distinguishes PTY from non-PTY execution paths
  • Provides user-facing error messages on command creation failure
  • Properly defers cleanup for PTY commands

95-131: Previous issue resolved: Stderr stream separation.

Line 111 now correctly routes stderr to session.Stderr() instead of the main session stream, allowing clients to distinguish stdout from stderr output. This addresses the previous review concern.

The command execution flow is well-structured with proper:

  • Process group setup
  • Stdin pipe management
  • Working directory validation
  • Error handling

133-143: LGTM: Clean stdin handling.

The IO handler correctly manages stdin with:

  • Deferred pipe close for cleanup
  • Error logging for diagnostic purposes

145-173: LGTM: Comprehensive cleanup with cancellation handling.

The cleanup logic properly handles both paths:

  • Normal completion: delegates to handleCommandCompletion
  • Cancellation: kills process group, enforces 5s timeout, exits with 130 (standard for interrupt)

175-207: LGTM: Correct exit code propagation.

Both handlers correctly manage command completion:

  • handleCommandCompletion: Routes success/failure appropriately
  • handleSessionExit: Properly extracts exit codes from ExitError and defaults to 1 for non-exit errors
client/ssh/proxy/proxy.go (3)

42-68: gRPC connection cleanup implemented.

The gRPC connection leak flagged in previous reviews has been properly addressed. The connection is now stored in the struct and released via the Close() method.


255-299: SFTP subsystem implementation looks solid.

The SFTP bridge properly handles session lifecycle, pipe setup, bidirectional I/O copying, and context cancellation. Error handling and cleanup are appropriate.


251-361: Port forwarding correctly disabled in proxy mode.

The proxy appropriately rejects direct-tcpip and tcpip-forward requests. This is a reasonable design decision since port forwarding would bypass the proxy's authentication/verification layer.

client/ssh/client/client.go (6)

36-124: Client lifecycle management looks solid.

The Client struct properly wraps crypto/ssh.Client with lifecycle management, terminal state restoration, and context-aware session handling. Cleanup paths are well-structured.


126-231: Command execution methods are well-designed.

The three execution variants (ExecuteCommand, ExecuteCommandWithIO, ExecuteCommandWithPTY) provide appropriate levels of control. Context cancellation sends SIGTERM with a grace period, and error handling properly distinguishes SSH exit errors from connection failures.


284-311: Daemon address resolution timing fixed.

The daemon address is now properly resolved before createHostKeyCallback (lines 285-289), ensuring daemon-backed host key verification works correctly. This addresses the previous review concern about "key not found in NetBird daemon" errors.


462-472: Host key verification error message improved.

The error message at line 471 now accurately reflects that only known_hosts files are checked in this fallback path. Consider including the hostname placeholder %s in the actual formatted output for better diagnostics.


507-537: Local port forwarding context cancellation fixed.

The listener is now properly closed when the context is cancelled (lines 532-536), addressing the previous review concern. This ensures the accept goroutine terminates and the port is released.


574-693: Remote port forwarding implementation looks correct.

The remote forwarding flow properly handles tcpip-forward requests, channel acceptance, cancellation, and bidirectional I/O. Context cancellation is properly wired through the channel handling loop.

client/cmd/ssh.go (8)

66-91: Flag registration and subcommand wiring look correct.

The init() function properly registers SSH-related flags on upCmd and sshCmd, and wires the new subcommands (sftp, proxy, detect) into the command tree.


130-175: Goroutine error handling properly fixed.

The direct os.Exit(1) call in the goroutine has been replaced with proper error propagation via errCh (lines 156-170). This allows deferred cleanup and signal handlers to execute correctly.


235-360: Custom global flag parsing is well-structured.

The custom parsing logic (extractGlobalFlags, parseGlobalArgs, parseFlag, etc.) properly handles global flags that appear before the ssh subcommand. The handler-map pattern and separate equals/spaced-format parsers make the code maintainable despite DisableFlagParsing requirements.


463-498: Hostname and command parsing is well-structured.

The parseHostnameAndCommand function properly handles user@host format, provides sensible username fallbacks (SUDO_USER → current user → root), and extracts commands from remaining arguments.


500-572: SSH execution flow is clean and correct.

The runSSH function properly orchestrates connection establishment, cleanup, port forwarding, and command/terminal execution. Exit code propagation via os.Exit at line 555 is appropriate in this context (after returning from the goroutine, preserving SSH exit semantics).


627-720: Port forward specification parsing covers common formats.

The parsing logic handles 2/3/4-part specifications, IPv6 addresses, and Unix socket paths. The isUnixSocket heuristic (lines 710-712) is intentionally simple—sufficient for distinguishing socket syntax from network addresses at parse time, as clarified in previous review discussions.


722-763: SSH proxy command implementation looks solid.

The sshProxyFn properly initializes the proxy, handles JWT authentication, and ensures cleanup via defer proxy.Close(). Error handling and logging setup are appropriate.


774-795: SSH detect command uses exit codes as output.

The sshDetectFn calls os.Exit directly with specific exit codes (0, 1, 2) to signal server type to SSH's Match exec directive. As discussed in previous reviews, this design is acceptable for an internal utility command where the exit code is the output rather than an error condition.

client/ui/client_ui.go (7)

262-295: Struct extensions for SSH settings and TTL look consistent

The added UI widget pointers and mirrored boolean/int fields on serviceClient align with existing patterns (e.g., network flags) and give a clear source of truth for change detection. No functional concerns here.


442-447: SSH controls wiring and resized settings window are fine

The new SSH-related checkboxes and TTL entry are correctly instantiated and later used in forms and request building; bumping the settings window size to 600×400 matches the new tabbed layout.

Also applies to: 450-451


579-605: SetConfigRequest construction and TTL validation are sound

The new buildSetConfigRequest correctly:

  • Uses active profile + current OS user.
  • Maps interface/MTU and network flags into the protobuf.
  • Adds all SSH toggles to SetConfigRequest.

For SSH JWT cache TTL:

  • It trims the text, parses as int32, enforces 0 ≤ TTL ≤ maxSSHJWTCacheTTL, and only then assigns to req.SshJWTCacheTTL.
  • The "0=disabled" semantics match the SSH tab label.

No functional issues; the TTL handling addresses prior concerns about missing upper bounds.

Also applies to: 612-617, 618-629, 631-633


638-672: Config update and reconnect logic looks correct

sendConfigUpdate reuses the daemon client, performs SetConfig, and, if the service is currently connected, asynchronously issues a Down followed by Up to apply changes. Errors are logged and won’t crash the UI. This is a reasonable trade-off between responsiveness and simplicity.


674-694: Tabbed settings UI is a solid improvement

The new getSettingsForm with Connection/Network/SSH tabs, shared Save/Cancel buttons, and separate getNetworkForm/getSSHForm helpers makes the settings much clearer and keeps each concern localized. Wiring Save to s.saveSettings and Cancel to just close the window is consistent and predictable.

Also applies to: 696-706, 708-718


721-737: SSH change detection behavior on invalid TTL is sensible

hasSSHChanges:

  • Treats an unparseable TTL as “changed” to force validation on save.
  • Compares the parsed TTL against the baseline sshJWTCacheTTL otherwise, along with all SSH toggles.

This matches buildSetConfigRequest’s stricter validation and ensures that invalid TTL edits aren’t silently ignored.


1385-1393: Proto → config mapping for SSH fields and TTL is consistent

Mapping the new SSH flags and TTL from GetConfigResponse into profilemanager.Config mirrors how existing fields are handled and provides a single source for both the UI and other client components. Using a pointer for SSHJWTCacheTTL is appropriate and keeps the “0=disabled” semantics while still distinguishing presence from absence at the config layer.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/cmd/ssh.go (1)

85-86: Redundant flag registrations for -L and -R (documentation only).

These StringArrayP calls register -L and -R flags but don't bind them to variables, and they're never used for parsing since DisableFlagParsing: true is set on line 124. They exist solely for help text generation, while the actual parsing happens in parseCustomSSHFlags (lines 210-213). This creates maintenance overhead—updating the behavior requires changing both the help text and the parsing logic.

Consider adding a comment clarifying that these registrations are for documentation only, or refactor to use Cobra's native flag parsing by removing DisableFlagParsing and binding these flags to localForwards and remoteForwards directly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f7a37ed and c8fae06.

📒 Files selected for processing (1)
  • client/cmd/ssh.go (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/cmd/ssh.go
🧬 Code graph analysis (1)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (31-31)
  • New (170-184)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (46-61)
client/ssh/client/client.go (3)
  • Dial (284-311)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
🔇 Additional comments (2)
client/cmd/ssh.go (2)

381-411: Flag bindings correctly implemented.

All long-form and short-form flags are properly bound using IntVar, StringVar, and BoolVar, ensuring that both --port and -p (and similar pairs) update the same struct fields. This addresses the issue raised in previous reviews.


156-175: Clean error propagation with context cancellation.

The error handling properly uses a buffered channel (errCh) to propagate errors from the SSH goroutine, avoiding direct os.Exit calls and allowing deferred cleanup to execute. The signal handling and context cancellation flow is correct.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
management/server/account.go (1)

1228-1228: Fix typo in log message.

The log message contains "accountssssssss" instead of "account".

Apply this diff:

-		log.Errorf("failed to get account onboarding for accountssssssss %s: %v", accountID, err)
+		log.Errorf("failed to get account onboarding for account %s: %v", accountID, err)
🧹 Nitpick comments (2)
client/server/server_test.go (1)

320-321: BuildManager call signature fixed

Config passed as second arg looks good. Consider renaming local variable from config to cfg to avoid shadowing the imported package name.

client/internal/engine_test.go (1)

219-223: Optional: Remove unreachable return statements.

The return statements at lines 222 and 228 are unreachable because t.Fatal terminates execution. While harmless, removing them would clean up the code.

Apply this diff:

 	key, err := wgtypes.GeneratePrivateKey()
 	if err != nil {
 		t.Fatal(err)
-		return
 	}

 	sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
 	if err != nil {
 		t.Fatal(err)
-		return
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c8fae06 and 30518f4.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (14)
  • .github/workflows/check-license-dependencies.yml (1 hunks)
  • client/cmd/ssh.go (1 hunks)
  • client/cmd/testutil_test.go (2 hunks)
  • client/internal/engine_test.go (9 hunks)
  • client/server/server_test.go (2 hunks)
  • client/ui/client_ui.go (7 hunks)
  • go.mod (8 hunks)
  • management/internals/server/modules.go (1 hunks)
  • management/internals/shared/grpc/conversion.go (4 hunks)
  • management/internals/shared/grpc/server.go (1 hunks)
  • management/server/account.go (14 hunks)
  • management/server/account/manager.go (3 hunks)
  • management/server/account_test.go (26 hunks)
  • management/server/dns_test.go (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • client/cmd/ssh.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • go.mod
🧰 Additional context used
🧬 Code graph analysis (9)
management/internals/server/modules.go (1)
management/server/account.go (1)
  • BuildManager (178-264)
client/ui/client_ui.go (3)
client/server/server.go (1)
  • New (99-110)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (12)
  • SetConfigRequest (3918-3959)
  • SetConfigRequest (3972-3972)
  • SetConfigRequest (3987-3989)
  • StatusRequest (850-858)
  • StatusRequest (871-871)
  • StatusRequest (886-888)
  • DownRequest (973-977)
  • DownRequest (990-990)
  • DownRequest (1005-1007)
  • UpRequest (762-768)
  • UpRequest (781-781)
  • UpRequest (796-798)
client/server/server_test.go (2)
management/server/account.go (1)
  • BuildManager (178-264)
management/server/integrations/port_forwarding/controller.go (1)
  • NewControllerMock (20-22)
management/server/dns_test.go (3)
management/server/account.go (1)
  • BuildManager (178-264)
management/server/integrated_validator.go (6)
  • MockIntegratedValidator (125-128)
  • MockIntegratedValidator (153-155)
  • MockIntegratedValidator (157-159)
  • MockIntegratedValidator (161-163)
  • MockIntegratedValidator (165-167)
  • MockIntegratedValidator (169-171)
management/server/integrations/port_forwarding/controller.go (1)
  • NewControllerMock (20-22)
management/server/account/manager.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
client/cmd/testutil_test.go (1)
management/server/account.go (1)
  • BuildManager (178-264)
client/internal/engine_test.go (4)
shared/management/proto/management.pb.go (6)
  • SSHConfig (2091-2102)
  • SSHConfig (2117-2117)
  • SSHConfig (2132-2134)
  • JWTConfig (1619-1628)
  • JWTConfig (1643-1643)
  • JWTConfig (1658-1660)
client/ssh/server/server.go (1)
  • JWTConfig (144-149)
client/internal/engine.go (2)
  • Engine (138-211)
  • EngineConfig (81-135)
management/server/account.go (1)
  • BuildManager (178-264)
management/server/account.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
management/server/account_test.go (2)
shared/auth/user.go (1)
  • UserAuth (7-28)
management/server/account.go (1)
  • BuildManager (178-264)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: JS / Lint
  • GitHub Check: release_ui_darwin
  • GitHub Check: iOS / Build
  • GitHub Check: Android / Build
  • GitHub Check: Linux
  • GitHub Check: Darwin
  • GitHub Check: release
  • GitHub Check: release_ui
  • GitHub Check: Build Cache
  • GitHub Check: Windows
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
  • GitHub Check: Client / Unit
🔇 Additional comments (28)
.github/workflows/check-license-dependencies.yml (1)

22-52: Improved bash practices and clearer licensing messaging.

The refactored logic properly uses process substitution and explicit error handling. The changes are sound:

  • Process substitution (line 42): done < <(find ...) avoids subshell scoping issues compared to piping, ensuring variables persist correctly.
  • Error suppression (line 34): 2>/dev/null cleanly suppresses permission errors.
  • Condition logic (line 35): Correctly changed from -z (empty) to -n (non-empty) to detect problematic matches.
  • Licensing message (line 47): Explicitly references AGPLv3 constraints, providing clearer guidance than the original wording.

The workflow remains robust and maintainable.

Please verify that the AGPLv3 licensing constraints referenced on line 47 correctly align with any new SSH-related dependencies introduced in this PR. Have any AGPLv3-licensed packages been added as dependencies?

client/ui/client_ui.go (7)

58-58: LGTM: Clear constant for SSH JWT Cache TTL maximum.

The 24-hour maximum is appropriate for JWT caching and the constant improves maintainability.


269-300: LGTM: SSH-related fields follow existing conventions.

The widget declarations and state tracking fields are well-organized and consistent with the existing codebase structure.


489-682: LGTM: Settings save flow is well-structured.

The refactored flow properly validates inputs, builds the configuration request, sends updates, and handles errors gracefully. The dialog now stays open on validation failures (addressing previous feedback), and the reconnection logic correctly applies settings changes to active connections.


684-729: LGTM: Tabbed settings form improves organization.

The refactored UI separates connection, network, and SSH settings into logical tabs. The SSH JWT Cache TTL label clearly indicates that 0 disables the feature.


628-639: LGTM: SSH JWT Cache TTL validation is thorough.

The validation properly checks the range [0, 86400] and provides clear error messages. The decision to treat an empty field as "no change" is reasonable, though it requires consistent handling in hasSSHChanges (addressed in separate comment).


1268-1325: LGTM: SSH config loading handles optional fields correctly.

The defensive nil-checks ensure the code handles missing SSH configuration gracefully. State and UI updates only occur when the server provides values.


1395-1402: LGTM: Proto-to-config SSH field mapping is correct.

The conversion properly maps all SSH-related fields from the protobuf response to the config structure, with appropriate type conversions for the TTL field.

client/cmd/testutil_test.go (1)

121-121: BuildManager call updated correctly

Passing config as the second argument matches the new signature. LGTM.

management/internals/server/modules.go (1)

69-69: BuildManager wiring includes server config

Using s.config as the new second parameter is correct. LGTM.

management/internals/shared/grpc/server.go (1)

649-649: toPeerConfig now receives server config

The additional config argument is correctly passed. LGTM.

management/server/account_test.go (2)

2962-2963: Test helper BuildManager call adjusted

Added nil as the new second parameter (config). Looks correct.


47-48: Verification confirmed: no stale UserAuth references remain

All references in management/server/account_test.go consistently use the new auth.UserAuth type from github.com/netbirdio/netbird/shared/auth (imported at line 47). Migration is complete across all 30+ usages throughout the file. No references to old nbcontext.UserAuth or management/server/auth.UserAuth types detected.

management/internals/shared/grpc/conversion.go (2)

86-105: Peer SSH config assembled correctly

toPeerConfig now includes SSH settings and receives server config. LGTM on structure.


86-105: Review comment is incorrect

The review assumes a TTL field exists in the SSHConfig proto message, but verification shows:

  1. The SSHConfig message in shared/management/proto/management.proto contains only: sshEnabled, sshPubKey, and jwtConfig—no TTL field.
  2. The JWTConfig message lacks TTL fields as well (contains only issuer, audience, keysLocation, maxTokenAge).
  3. The file management/internals/shared/grpc/conversion.go is not modified in this PR (only client/cmd/ssh.go changed).
  4. No proto definition changes exist in this PR that would add a TTL field to SSHConfig.

The sshJWTCacheTTL fields found in client/proto/daemon.proto are in different message structures unrelated to the management server's SSHConfig.

Likely an incorrect or invalid review comment.

management/server/dns_test.go (1)

227-228: BuildManager call signature aligned

Added nil as the new config argument. LGTM.

management/server/account/manager.go (1)

9-10: UserAuth type migration verified—no issues found

The codebase has been successfully migrated to use github.com/netbirdio/netbird/shared/auth.UserAuth consistently. All interface methods, implementers, mocks, and call sites (lines 49–53, 121–127, and throughout) use the correct shared auth type. No old management/server/auth.UserAuth or mixed-type usages detected.

client/internal/engine_test.go (7)

27-27: LGTM! Import changes are clean.

The stdnet import addition and nbssh aliasing align well with the SSH subsystem refactoring.

Also applies to: 51-51


225-229: LGTM! SSH key generation and EngineConfig wiring are correct.

The ED25519 key generation follows best practices, and passing the SSH key to EngineConfig aligns with the new SSHKey field in engine.go.

Also applies to: 247-247


259-259: LGTM! Error handling improvements are idiomatic.

Switching to require.NoError is the standard testify pattern, and the added nil assertions for sshServer provide good cleanup verification.

Also applies to: 285-285, 307-307, 320-320, 335-338


293-301: LGTM! JwtConfig structure matches protobuf definitions.

The JWT configuration fields (Issuer, Audience, KeysLocation, MaxTokenAge) align with the protobuf definitions and support the new JWT-based SSH authentication flow.


340-399: LGTM! New test functions provide solid coverage.

TestEngine_SSHUpdateLogic validates that the SSH server respects configuration flags (BlockInbound, ServerSSHAllowed), and TestEngine_SSHServerConsistency ensures error handling leaves no partial state. Both add valuable coverage for the SSH lifecycle.


813-816: LGTM! Network setup is correct.

Using stdnet.NewNet for test network isolation is appropriate, and error handling is correct.


1628-1628: LGTM! BuildManager call updated correctly.

The call site correctly passes the new config parameter as the second argument, matching the updated signature in account.go.

management/server/account.go (4)

18-18: LGTM! Import additions support the refactoring.

The shared/auth and nbconfig imports enable the UserAuth type migration and server configuration integration.

Also applies to: 30-30


87-88: LGTM! Config field addition is well-documented.

Adding server configuration access to the account manager enables configuration-driven behavior and is properly initialized in BuildManager.


180-180: LGTM! BuildManager signature updated correctly.

The addition of the config parameter as the second argument is a breaking but necessary change. The config is properly stored in the manager struct and call sites are updated throughout the PR.

Also applies to: 202-202


1017-1017: LGTM! UserAuth type migration is consistent.

The mechanical refactoring from nbcontext.UserAuth to auth.UserAuth is applied consistently across all function signatures. The type structure remains identical, so no behavior changes are expected.

Also applies to: 1066-1066, 1085-1085, 1116-1116, 1280-1280, 1324-1324, 1482-1482, 1561-1561, 1609-1609

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
management/internals/shared/grpc/conversion.go (1)

365-397: Default JWKS path follows OIDC standard but may not work for all providers.

The code falls back to issuer + "/.well-known/jwks.json" when AuthKeysLocation is not explicitly configured (lines 387-390). While this follows the OIDC discovery standard and works for most providers, it may fail for some (e.g., certain Okta or Keycloak configurations with custom paths).

The current implementation is a pragmatic compromise: it requires an explicit issuer (returns nil if missing) and allows explicit AuthKeysLocation override, but provides a sensible default for standard OIDC providers. Given that the author marked this as "fixed" and the approach aligns with OIDC standards, this is acceptable.

client/ssh/server/command_execution.go (1)

60-89: The UsedFallback logic issue persists despite past review.

Line 77 still treats privilegeResult.UsedFallback as an error condition alongside actual errors. According to the PrivilegeCheckResult documentation in user_utils.go, when UsedFallback=true, "all privilege checks have already been performed and no additional privilege dropping or root checks are needed."

The current flow attempts createSuCommand even when UsedFallback=true, which is semantically incorrect. When no user switching is required (indicated by RequiresUserSwitching=false or UsedFallback=true), we should skip su entirely and call createExecutorCommand directly.

Apply this refactoring to check privilege switching semantics upfront:

 func (s *Server) createCommand(privilegeResult PrivilegeCheckResult, session ssh.Session, hasPty bool) (*exec.Cmd, func(), error) {
 	localUser := privilegeResult.User
 
 	// If PTY requested but su doesn't support --pty, skip su and use executor
 	// This ensures PTY functionality is provided (executor runs within our allocated PTY)
 	if hasPty && !s.suSupportsPty {
 		log.Debugf("PTY requested but su doesn't support --pty, using executor for PTY functionality")
 		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
 		if err != nil {
 			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
 		}
 		cmd.Env = s.prepareCommandEnv(localUser, session)
 		return cmd, cleanup, nil
 	}
 
+	// If no user switching is needed, use executor directly
+	if !privilegeResult.RequiresUserSwitching {
+		log.Debugf("no user switching required, using executor")
+		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
+		if err != nil {
+			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
+		}
+		cmd.Env = s.prepareCommandEnv(localUser, session)
+		return cmd, cleanup, nil
+	}
+
 	// Try su first for system integration (PAM/audit) when privileged
 	cmd, err := s.createSuCommand(session, localUser, hasPty)
-	if err != nil || privilegeResult.UsedFallback {
+	if err != nil {
 		log.Debugf("su command failed, falling back to executor: %v", err)
 		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
 		if err != nil {
 			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
 		}
 		cmd.Env = s.prepareCommandEnv(localUser, session)
 		return cmd, cleanup, nil
 	}
 
 	cmd.Env = s.prepareCommandEnv(localUser, session)
 	return cmd, func() {}, nil
 }

Based on learnings

🧹 Nitpick comments (3)
client/ssh/server/userswitching_unix.go (2)

62-106: Avoid hard failure if remote address can’t be parsed for login -h

getLoginCmd currently fails the whole PTY setup if netip.ParseAddrPort(remoteAddr.String()) fails, or if login is missing. That’s reasonable in many cases, but on atypical transports (non-TCP net.Addr implementations, funky String() formats, or minimal systems without login) this will turn into a user-visible session failure even though we could still start a shell without -h.

Consider degrading gracefully when the host can’t be parsed or -h isn’t supported instead of returning an error, e.g. by omitting the -h argument and logging a warning:

 func (s *Server) getLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) {
 	loginPath, err := exec.LookPath("login")
 	if err != nil {
 		return "", nil, fmt.Errorf("login command not available: %w", err)
 	}
 
-	addrPort, err := netip.ParseAddrPort(remoteAddr.String())
-	if err != nil {
-		return "", nil, fmt.Errorf("parse remote address: %w", err)
-	}
+	var host string
+	if addrPort, err := netip.ParseAddrPort(remoteAddr.String()); err == nil {
+		host = addrPort.Addr().String()
+	} else {
+		log.Warnf("SSH login: unable to parse remote address %q, proceeding without -h: %v", remoteAddr.String(), err)
+	}
 
 	switch runtime.GOOS {
 	case "linux":
 		// Special handling for Arch Linux without /etc/pam.d/remote
 		if s.fileExists("/etc/arch-release") && !s.fileExists("/etc/pam.d/remote") {
 			return loginPath, []string{"-f", username, "-p"}, nil
 		}
-		return loginPath, []string{"-f", username, "-h", addrPort.Addr().String(), "-p"}, nil
+		if host != "" {
+			return loginPath, []string{"-f", username, "-h", host, "-p"}, nil
+		}
+		return loginPath, []string{"-f", username, "-p"}, nil
 	case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
-		return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil
+		if host != "" {
+			return loginPath, []string{"-fp", "-h", host, username}, nil
+		}
+		return loginPath, []string{"-fp", username}, nil
 	default:
 		return "", nil, fmt.Errorf("unsupported Unix platform for login command: %s", runtime.GOOS)
 	}
 }

This keeps the “best-effort” -h behavior where possible but avoids breaking sessions solely due to address formatting or missing PAM configs.


108-153: Credential parsing and group fallback are robust; minor duplication opportunity

Parsing UID/GID as 32-bit and falling back to the primary GID when supplementary group lookup fails is a good, defensive default that avoids hard failures when NSS is misconfigured.

If you ever want to simplify this path, you could avoid the second user.Lookup in getSupplementaryGroups and instead call localUser.GroupIds() directly from parseUserCredentials, since you already have *user.User there. That would remove one extra lookup without changing behavior.

client/ssh/server/userswitching_windows.go (1)

18-87: ---

Windows username validation follows sAMAccountName limits but may reject modern local accounts

The 20-character cap aligns with the legacy sAMAccountName constraint (pre-Windows 2000 standard), which is appropriate for backward compatibility with older tools and services. However, modern Windows local accounts support names up to 256 characters, and UPN / email-style logon names can be significantly longer.

The current validation will reject legitimate modern local accounts that exceed 20 characters. If your SSH server environment includes modern Windows local accounts or UPN-based identities, consider relaxing or making the length limit configurable. For now, the conservative 20-character enforcement is a reasonable choice for legacy SAM compatibility, with the understanding that it trades broader account support for stricter backward compatibility.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 30518f4 and 5f6d415.

📒 Files selected for processing (9)
  • client/cmd/ssh.go (1 hunks)
  • client/cmd/ssh_test.go (1 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/user_utils.go (1 hunks)
  • client/ssh/server/userswitching_unix.go (1 hunks)
  • client/ssh/server/userswitching_windows.go (1 hunks)
  • client/ui/client_ui.go (7 hunks)
  • management/internals/shared/grpc/conversion.go (4 hunks)
  • management/server/account.go (15 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/cmd/ssh.go
  • client/ssh/server/user_utils.go
  • client/ssh/server/command_execution.go
  • client/ssh/server/userswitching_windows.go
  • client/ssh/server/userswitching_unix.go
🧬 Code graph analysis (7)
client/ui/client_ui.go (4)
client/server/server.go (1)
  • New (99-110)
client/ssh/server/server.go (1)
  • New (170-184)
client/iface/iface.go (2)
  • MinMTU (29-29)
  • MaxMTU (30-30)
client/proto/daemon.pb.go (3)
  • SetConfigRequest (3918-3959)
  • SetConfigRequest (3972-3972)
  • SetConfigRequest (3987-3989)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (31-31)
  • New (170-184)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (46-61)
client/ssh/client/client.go (3)
  • Dial (284-311)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/ssh/server/user_utils.go (1)
client/ssh/server/server.go (4)
  • New (170-184)
  • Server (115-142)
  • PrivilegedUserError (52-54)
  • UserNotFoundError (65-68)
client/ssh/server/command_execution.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/userswitching_windows.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/executor_windows.go (2)
  • WindowsExecutorConfig (30-41)
  • NewPrivilegeDropper (45-47)
client/ssh/server/userswitching_unix.go (4)
client/ssh/server/server.go (2)
  • New (170-184)
  • Server (115-142)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • ExecutorConfig (27-35)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
management/server/account.go (1)
shared/auth/user.go (1)
  • UserAuth (7-28)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (24)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Linux
  • GitHub Check: Darwin
  • GitHub Check: Windows
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: release_ui
  • GitHub Check: release_ui_darwin
  • GitHub Check: Client / Unit
  • GitHub Check: release
  • GitHub Check: Android / Build
  • GitHub Check: Client / Unit
🔇 Additional comments (34)
management/internals/shared/grpc/conversion.go (4)

6-7: LGTM: Imports support JWT config derivation.

The new imports net/url and strings are appropriately added to support the JWT configuration derivation logic in buildJWTConfig and deriveIssuerFromTokenEndpoint.


86-104: LGTM: SSH config construction properly conditionalizes JWT config.

The refactoring extracts SSH config construction into a clear two-step process: first creating the base config with SshEnabled, then conditionally populating JwtConfig only when SSH is enabled. This approach is clean and avoids unnecessary JWT config derivation for non-SSH peers.


109-109: LGTM: Config parameter properly threaded through.

The call site correctly passes the config parameter to toPeerConfig, maintaining consistency with the updated signature.


399-411: LGTM: Issuer derivation logic is correct.

The function properly extracts the issuer URL from a token endpoint by parsing the URL and formatting it as scheme://host/. Error handling is appropriate, returning an empty string for invalid input rather than panicking.

management/server/account.go (5)

18-18: LGTM: Imports support auth type migration and config integration.

The new imports enable the migration to auth.UserAuth from the shared auth package and the integration of management server configuration for JWT config derivation used in SSH features.

Also applies to: 30-30


87-88: LGTM: Config field properly documented.

The new config field is clearly documented and will be used to provide JWT configuration to peers with SSH enabled.


178-220: LGTM: Config parameter properly integrated into BuildManager.

The config parameter is correctly added to the BuildManager signature and stored in the DefaultAccountManager struct. This enables SSH-enabled peers to receive JWT configuration for SSH authentication. The parameter ordering (after context, before store) is logical.


1228-1228: LGTM: Typo fix in error log.

The error log message is corrected from "accountssssssss" to "account", improving log readability.


1017-1017: LGTM: Auth type migration is consistent and correct.

All function signatures are consistently updated to use auth.UserAuth from the shared auth package instead of nbcontext.UserAuth. This consolidation aligns with the broader refactoring to centralize auth types. The mechanical nature of these changes reduces the risk of introducing bugs.

Also applies to: 1066-1066, 1085-1085, 1116-1116, 1280-1280, 1324-1324, 1482-1482, 1561-1561, 1609-1609

client/cmd/ssh.go (1)

1-808: LGTM! Previous review concerns have been addressed.

The SSH command implementation is comprehensive and well-structured. All issues from previous reviews have been successfully addressed:

  • Long-form flags (--port, --user, --known-hosts, --identity, --config, --log-level) are properly bound using IntVar/StringVar/BoolVar (lines 390-408)
  • Error propagation from the SSH goroutine now uses an error channel pattern instead of direct os.Exit (lines 156-162)
  • Parse errors are returned directly rather than falling back and masking invalid flags (lines 428-430)

The remaining direct os.Exit calls in sshDetectFn (lines 787-808) and executeSSHCommand (line 555) serve specific purposes (SSH server detection exit codes and SSH command exit code propagation) and have been accepted by maintainers.

The implementation provides robust SSH functionality with proper context handling, port forwarding, error reporting, and comprehensive flag parsing.

client/cmd/ssh_test.go (1)

1-717: Excellent test coverage!

This comprehensive test suite provides strong validation for the SSH command implementation:

  • Flag parsing tests cover basic scenarios, user@host format, commands with flags, and double-dash separators
  • Conflict prevention tests ensure SSH flags don't interfere with remote command flags (e.g., ls -la, grep -r)
  • Non-interactive execution tests validate command execution behavior
  • Regression tests guard against reintroduction of past issues (e.g., ls -la being misparsed)
  • Port forwarding tests cover local/remote forwards, multiple forwards, bind addresses, Unix sockets, and wildcard handling
  • Invalid flag rejection tests ensure proper error handling and prevent invalid flags from being misinterpreted as hostnames

The tests validate fixes for issues identified in previous reviews (particularly invalid flag rejection at lines 671-717) and provide good coverage of edge cases.

client/ssh/server/user_utils.go (7)

1-39: LGTM! Clean foundation with good testing support.

The dependency injection pattern for testing runtime checks is well-designed, and the error definitions are clear and appropriately exported.


41-110: LGTM! Well-documented privilege checking API.

The PrivilegeCheckResult.UsedFallback field documentation clearly states the semantic intent: "all privilege checks have already been performed and no additional privilege dropping or root checks are needed." This design properly separates privilege checking from execution logic.


112-176: LGTM! Robust privilege checking flow.

The Unix non-privileged shortcut correctly sets UsedFallback=true when falling back to the current user, and the RequiresUserSwitching calculation even on lookup failure ensures accurate metadata for callers.


193-254: Windows domain logic appears sound but complex.

The user comparison logic correctly handles domain-qualified usernames. The key decision at lines 246-250 (allowing bare username to match domain-qualified current user) aligns with expected Windows behavior where local user context is assumed when no domain is specified.


256-291: LGTM! Clean wrapper methods with proper synchronization.

The wrapper methods appropriately delegate to CheckPrivileges and handle the result correctly.


293-343: LGTM! Comprehensive privileged user detection.

The privileged username detection covers Unix (root) and Windows (Administrator, SYSTEM, etc.) with appropriate domain stripping. The fail-safe behavior on lookup errors (returning false) is correct for security.


345-411: LGTM! Thorough Windows privilege detection with proper RID handling.

The SID checking logic comprehensively covers well-known privileged accounts and domain admin RIDs. The error handling in isWindowsElevated correctly defaults to non-privileged on failure, maintaining security.

client/ssh/server/command_execution.go (4)

15-58: LGTM! Clear command execution entry point with appropriate error handling.

The PTY vs. non-PTY path selection is clean, and user-facing error messages provide helpful context without exposing internal details.


91-127: LGTM! Robust command execution with proper stream separation.

The stderr wiring at line 107 correctly directs error output to session.Stderr(), maintaining proper stream separation for SSH clients. The working directory fallback (lines 109-114) provides good resilience.


129-169: LGTM! Proper I/O handling with graceful cancellation.

The cancellation flow (lines 149-164) correctly terminates the process group and waits with a timeout. Exit code 130 is conventional for interrupted commands.


171-203: LGTM! Correct exit code propagation.

The exit handling properly preserves command exit codes from exec.ExitError and provides sensible defaults for other error types.

client/ui/client_ui.go (10)

58-58: LGTM - Well-chosen JWT cache TTL maximum.

The 24-hour maximum for SSH JWT cache TTL is a reasonable security boundary and is clearly defined as a constant for maintainability.


269-300: LGTM - SSH state fields properly declared.

The SSH-related UI widgets and state fields follow the existing naming conventions and are properly organized with clear intent.


489-522: LGTM - Robust settings save flow.

The refactored save flow properly validates inputs, handles errors, and keeps the dialog open when validation fails, allowing users to correct their input. The feature flag check ensures configuration updates respect daemon-level restrictions.


524-555: LGTM - Comprehensive input validation.

The validation logic properly checks port range (1-65535), MTU bounds (using iface.MinMTU and iface.MaxMTU), and pre-shared key format with clear, actionable error messages.


589-646: LGTM - Well-structured config request builder.

The request builder properly validates SSH JWT cache TTL (0 to 86,400 seconds), includes SSH checkbox settings unconditionally (they always have a value), and only includes the TTL field when explicitly set by the user (empty field means "no change"). The validation and error handling are correct.


684-729: LGTM - Clean tabbed settings interface.

The refactored settings UI cleanly organizes options into Connection, Network, and SSH tabs with proper button wiring. The 600×400 window size may feel tight with 19 total form items across tabs, but the tabbed approach makes navigation manageable.


731-747: LGTM - Correct SSH change detection logic.

The change detection properly handles all SSH settings: empty TTL field preserves current value, unparseable input triggers validation, and all SSH state changes are correctly detected. This aligns with the request-building logic in buildSetConfigRequest.


1268-1324: LGTM - SSH settings properly loaded and bound to UI.

The server config loading correctly handles SSH settings with defensive nil checks throughout. The UI properly populates all SSH widgets from the loaded configuration.


1395-1402: LGTM - Proto config conversion handles SSH settings correctly.

The conversion from proto to internal config properly handles all SSH settings. The SSH JWT cache TTL is always converted to a non-nil pointer, treating unset proto values as 0 (disabled), which aligns with the UI's "0=disabled" semantics on line 726.


56-1405: Excellent SSH UI integration - all previous concerns addressed.

This comprehensive SSH feature integration demonstrates solid engineering:

  • Validation: Port (1-65535), MTU (576-8192), and SSH JWT cache TTL (0-86,400) are all validated with clear error messages
  • User experience: Settings dialog remains open on validation errors, allowing users to correct input without losing their changes
  • Architecture: Clean separation into Connection, Network, and SSH tabs with well-organized helper methods
  • Error handling: All error paths properly handled with user-friendly dialogs
  • Change detection: Smart logic that treats empty fields as "no change" and detects actual modifications
  • Integration: Feature flags properly checked, daemon reconnection handled gracefully

All issues from previous review rounds have been successfully resolved. The code is production-ready.

client/ssh/server/userswitching_unix.go (1)

25-60: POSIX-style username validation is sensible and conservative

The validation logic (non-empty, 32-char cap, portable character set, rejection of . / .., and warning-only handling of fully numeric names) looks appropriate for SSH user switching and avoids common footguns. It’s strict enough for security while still allowing typical Unix accounts.

client/ssh/server/userswitching_windows.go (1)

159-274: API stability confirmed—Windows privilege APIs remain unchanged; keep focused tests close to unsafe code

The Win32 privilege APIs (LookupPrivilegeValue, GetTokenInformation, AdjustTokenPrivileges) remain supported with no Microsoft deprecation announced, and there are no known deprecations or breaking changes in golang.org/x/sys/windows for these token APIs.

The hasPrivilege, enablePrivilege, and enableUserSwitching helpers implement the standard Windows token pattern correctly and close token handles safely on error paths. The unsafe pointer arithmetic around windows.Tokenprivileges follows the expected layout—no changes there.

Keep focused tests close to this code (especially verifying privilege absent, present-but-disabled, and successfully enabled states) to catch regressions and catch any layout shifts if x/sys/windows internals change.

@sonarqubecloud
Copy link

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
client/ssh/server/userswitching_unix.go (1)

188-200: Improve error context by checking privilegeResult.Error.

Good job adding the nil check for privilegeResult.User (this resolves the previous review concern). However, the error message can be improved by also checking privilegeResult.Error, which contains the reason for denial according to the PrivilegeCheckResult documentation.

Apply this diff to provide better error context:

 func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) {
 	localUser := privilegeResult.User
 	if localUser == nil {
+		if privilegeResult.Error != nil {
+			return nil, fmt.Errorf("privilege check failed: %w", privilegeResult.Error)
+		}
 		return nil, errors.New("no user in privilege result")
 	}

 	if privilegeResult.UsedFallback {
 		return s.createDirectPtyCommand(session, localUser, ptyReq), nil
 	}

 	return s.createPtyLoginCommand(localUser, ptyReq, session)
 }
client/ssh/server/command_execution_windows.go (1)

71-124: Profile handle must be unloaded to prevent resource leak.

LoadUserProfileW mounts the user's registry hive and returns a handle in profile.hProfile that must be released with UnloadUserProfileW. Without this cleanup, each SSH session leaks registry handles and mounted hives, eventually exhausting resources and blocking subsequent logons. This is a critical operational issue that will degrade service over time.

Apply this diff to properly release the profile handle:

 	userenv := windows.NewLazySystemDLL("userenv.dll")
 	loadUserProfileW := userenv.NewProc("LoadUserProfileW")
+	unloadUserProfileW := userenv.NewProc("UnloadUserProfileW")

 	ret, _, err := loadUserProfileW.Call(
 		uintptr(userToken),
 		uintptr(unsafe.Pointer(&profile)),
 	)

 	if ret == 0 {
 		return "", fmt.Errorf("LoadUserProfileW: %w", err)
 	}

 	if profile.lpProfilePath == nil {
 		return "", fmt.Errorf("LoadUserProfileW returned null profile path")
 	}

+	if profile.hProfile != 0 {
+		defer func() {
+			if _, _, unloadErr := unloadUserProfileW.Call(uintptr(userToken), uintptr(profile.hProfile)); unloadErr != windows.ERROR_SUCCESS {
+				log.Debugf("UnloadUserProfileW failed: %v", unloadErr)
+			}
+		}()
+	}
+
 	profilePath := windows.UTF16PtrToString(profile.lpProfilePath)
 	return profilePath, nil
🧹 Nitpick comments (2)
client/ssh/server/userswitching_unix.go (1)

131-153: Consider reusing the existing user.User object to avoid redundant lookup.

getSupplementaryGroups performs a fresh user.Lookup call, but the caller parseUserCredentials already has a *user.User object that was passed in. You can eliminate the redundant lookup by changing the signature to accept *user.User directly and calling localUser.GroupIds().

Apply this diff to eliminate the redundant lookup:

-func (s *Server) getSupplementaryGroups(username string) ([]uint32, error) {
-	u, err := user.Lookup(username)
-	if err != nil {
-		return nil, fmt.Errorf("lookup user %s: %w", username, err)
-	}
-
-	groupIDStrings, err := u.GroupIds()
+func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
+	groupIDStrings, err := u.GroupIds()
 	if err != nil {
-		return nil, fmt.Errorf("get group IDs for user %s: %w", username, err)
+		return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
 	}

 	groups := make([]uint32, len(groupIDStrings))
 	for i, gidStr := range groupIDStrings {
 		gid64, err := strconv.ParseUint(gidStr, 10, 32)
 		if err != nil {
-			return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, username, err)
+			return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err)
 		}
 		groups[i] = uint32(gid64)
 	}

 	return groups, nil
 }

And update the caller in parseUserCredentials:

-	groups, err := s.getSupplementaryGroups(localUser.Username)
+	groups, err := s.getSupplementaryGroups(localUser)
client/ssh/server/command_execution.go (1)

78-88: Consider skipping su when UsedFallback is true.

When privilegeResult.UsedFallback is true, the comment in user_utils.go indicates "all privilege checks have already been performed and no additional privilege dropping or root checks are needed - the current user is the target." The current code still attempts createSuCommand first, which will immediately fail and fall back to the executor. For semantic consistency and to avoid the unnecessary su attempt, consider checking UsedFallback upfront and going directly to the executor path when true.

Apply this diff for more semantically correct behavior:

 	// Try su first for system integration (PAM/audit) when privileged
-	cmd, err := s.createSuCommand(session, localUser, hasPty)
-	if err != nil || privilegeResult.UsedFallback {
+	if privilegeResult.UsedFallback {
+		log.Debugf("using fallback user, skipping su and using executor")
+		cmd, cleanup, err := s.createExecutorCommand(session, localUser, hasPty)
+		if err != nil {
+			return nil, nil, fmt.Errorf("create command with privileges: %w", err)
+		}
+		cmd.Env = s.prepareCommandEnv(localUser, session)
+		return cmd, cleanup, nil
+	}
+
+	cmd, err := s.createSuCommand(session, localUser, hasPty)
+	if err != nil {
 		log.Debugf("su command failed, falling back to executor: %v", err)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f6d415 and 0812992.

📒 Files selected for processing (4)
  • client/cmd/ssh.go (1 hunks)
  • client/ssh/server/command_execution.go (1 hunks)
  • client/ssh/server/command_execution_windows.go (1 hunks)
  • client/ssh/server/userswitching_unix.go (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-11-13T00:29:53.220Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/cmd/ssh_exec_unix.go:53-74
Timestamp: 2025-11-13T00:29:53.220Z
Learning: In client/ssh/server/executor_unix.go, the method ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) has a void return type (no error return). It handles failures by exiting the process directly with appropriate exit codes rather than returning errors to the caller.

Applied to files:

  • client/ssh/server/command_execution.go
  • client/cmd/ssh.go
  • client/ssh/server/command_execution_windows.go
  • client/ssh/server/userswitching_unix.go
📚 Learning: 2025-11-14T13:05:31.708Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/ssh/server/userswitching_windows.go:89-139
Timestamp: 2025-11-14T13:05:31.708Z
Learning: In client/ssh/server/executor_windows.go, the WindowsExecutorConfig struct's Pty, PtyWidth, and PtyHeight fields are intentionally left unused for now and will be implemented in a future update.

Applied to files:

  • client/ssh/server/command_execution_windows.go
  • client/ssh/server/userswitching_unix.go
📚 Learning: 2025-11-14T11:11:50.779Z
Learnt from: lixmal
Repo: netbirdio/netbird PR: 4015
File: client/ssh/server/server_test.go:396-406
Timestamp: 2025-11-14T11:11:50.779Z
Learning: On Windows, the NetBird SSH server only supports PowerShell as the shell (powershell.exe or pwsh.exe). cmd.exe and other shells are not supported due to parsing quirks and complexity.

Applied to files:

  • client/ssh/server/command_execution_windows.go
🧬 Code graph analysis (4)
client/ssh/server/command_execution.go (2)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/cmd/ssh.go (6)
client/ssh/server/server.go (2)
  • DefaultSSHPort (31-31)
  • New (170-184)
client/cmd/root.go (1)
  • SetFlagsFromEnvVars (198-219)
util/log.go (2)
  • FindFirstLogPath (77-84)
  • InitLog (32-74)
client/ssh/proxy/proxy.go (1)
  • New (46-61)
client/ssh/client/client.go (3)
  • Dial (284-311)
  • DialOptions (275-281)
  • Client (37-44)
client/ssh/detection/detection.go (4)
  • ServerTypeRegular (31-31)
  • Dialer (35-37)
  • Timeout (23-23)
  • DetectSSHServerType (59-99)
client/ssh/server/command_execution_windows.go (5)
client/ssh/server/server.go (1)
  • Server (115-142)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (1)
  • NewPrivilegeDropper (41-43)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
client/ssh/server/winpty/conpty.go (3)
  • PtyConfig (36-42)
  • UserConfig (45-48)
  • ExecutePtyWithUserToken (59-71)
client/ssh/server/userswitching_unix.go (4)
client/ssh/server/server.go (2)
  • New (170-184)
  • Server (115-142)
client/ssh/server/executor_windows.go (1)
  • NewPrivilegeDropper (45-47)
client/ssh/server/executor_unix.go (2)
  • NewPrivilegeDropper (41-43)
  • ExecutorConfig (27-35)
client/ssh/server/user_utils.go (1)
  • PrivilegeCheckResult (50-66)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (23)
  • GitHub Check: Management / Benchmark (amd64, postgres)
  • GitHub Check: Management / Integration (amd64, postgres)
  • GitHub Check: Management / Unit (amd64, mysql)
  • GitHub Check: Management / Unit (amd64, sqlite)
  • GitHub Check: Management / Benchmark (API) (amd64, sqlite)
  • GitHub Check: Management / Benchmark (API) (amd64, postgres)
  • GitHub Check: Relay / Unit (386)
  • GitHub Check: Management / Benchmark (amd64, sqlite)
  • GitHub Check: Client / Unit (386)
  • GitHub Check: Management / Unit (amd64, postgres)
  • GitHub Check: Relay / Unit (amd64, -race)
  • GitHub Check: Client / Unit (amd64)
  • GitHub Check: Client (Docker) / Unit
  • GitHub Check: Android / Build
  • GitHub Check: release
  • GitHub Check: release_ui_darwin
  • GitHub Check: Client / Unit
  • GitHub Check: JS / Lint
  • GitHub Check: Linux
  • GitHub Check: Client / Unit
  • GitHub Check: Darwin
  • GitHub Check: Windows
  • GitHub Check: Client / Unit
🔇 Additional comments (9)
client/ssh/server/userswitching_unix.go (5)

25-60: LGTM! Comprehensive username validation.

The validation logic follows POSIX standards with appropriate security checks (path components, numeric usernames) and practical limits.


76-100: LGTM! Platform-specific login command construction is correct.

The platform-specific handling (including the Arch Linux PAM edge case) is well-implemented with proper error handling.


62-74: LGTM! PTY command creation logic is correct.

Both createPtyLoginCommand and createDirectPtyCommand properly handle their respective privilege paths with appropriate environment and working directory setup.

Also applies to: 202-214


216-233: LGTM! Environment preparation logic is sound.

The environment assembly properly combines user, SSH, and terminal environments with appropriate filtering through acceptEnv for security.


155-181: All helper functions are properly defined in the codebase.

Verification confirms that getUserShell, prepareUserEnv, prepareSSHEnv, and acceptEnv are all defined in client/ssh/server/shell.go and are accessible within the same package. No issues found.

client/cmd/ssh.go (2)

390-408: Flag bindings correctly implemented.

Both short and long flag forms now properly bind to the same struct fields using IntVar/StringVar. For example, -p and --port both update flags.Port, -u and --user both update flags.Username, etc. This resolves the previous issue where long-form flags were not taking effect.


156-175: Error handling properly refactored.

The goroutine now sends errors through errCh instead of calling os.Exit directly, allowing proper cleanup via deferred functions and context cancellation. The main select handles the error channel, signal interrupts, and context completion correctly.

client/ssh/server/command_execution.go (1)

109-110: Stderr properly wired to separate stream.

The code now correctly routes stderr to session.Stderr() instead of collapsing both stdout and stderr to the session stream. This allows SSH clients and tooling to distinguish between the two streams.

client/ssh/server/command_execution_windows.go (1)

16-58: Command execution flow looks well-structured.

The Windows-specific command execution properly handles user environment retrieval, token management with cleanup via defer, and graceful fallback when profile loading fails. The flow correctly propagates the environment through to command preparation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected SSH access to routing peer despite non-existent policy

4 participants