Skip to content

[client] ssh: fix #5345 by implementing PTY support in executor and removing setsid wrapper#5382

Open
pieterhollander wants to merge 5 commits intonetbirdio:mainfrom
pieterhollander:fix/ssh-executor-pty-support
Open

[client] ssh: fix #5345 by implementing PTY support in executor and removing setsid wrapper#5382
pieterhollander wants to merge 5 commits intonetbirdio:mainfrom
pieterhollander:fix/ssh-executor-pty-support

Conversation

@pieterhollander
Copy link
Copy Markdown

@pieterhollander pieterhollander commented Feb 18, 2026

I tried to implement the existing TODO and fix #5345 myself.
I tested the PR on a few systems, including an Incus container.

  • Documentation is not needed for this change (explain why)

The change does not change anything for end-users, but fixes Netbird SSH being unusable on containers.

Summary by CodeRabbit

  • New Features

    • Enabled PTY support during privilege-drop execution with improved terminal preparation and privilege validation.
  • Documentation

    • Simplified the user-facing --pty flag help text.
  • Chores

    • Removed legacy util-linux login detection and streamlined startup/terminal handling.
  • Tests

    • Updated privilege-dropper tests to use dynamic user/group values and added a PTY-specific test.

Implement PTY support in the executor privilege-dropping path using setsid(2) + TIOCSCTTY to establish a controlling terminal natively, replacing the external setsid wrapper that was previously needed for util-linux login's vhangup() behavior.

- Add prepareControllingTerminal() with setsid/TIOCSCTTY and graceful error handling for containers and non-PTY scenarios
- Use syscall.Exec() in the PTY path so the shell directly replaces the executor process
- Remove setsid -c wrapper from getLinuxLoginCmd; pty.StartWithSize() sets Setsid and Setctty on the child process
- Remove detectUtilLinuxLogin() and loginIsUtilLinux field (now dead code)
- Fix executor tests to use current UID/GID instead of hardcoded 1000
- Add TestPrivilegeDropper_CreateExecutorCommandWithPTY
Copilot AI review requested due to automatic review settings February 18, 2026 13:31
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Feb 18, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements PTY support in the executor and removes the dependency on the external setsid wrapper for handling util-linux login's vhangup() requirements.

Changes:

  • Implemented PTY support directly in the executor using syscall.Setsid() and TIOCSCTTY ioctl
  • Removed util-linux login detection and setsid wrapper logic
  • Updated tests to use current user's UID/GID instead of hardcoded values

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
client/ssh/server/userswitching_unix.go Removed setsid wrapper logic and util-linux login handling, updated comments to reflect executor-based approach
client/ssh/server/server.go Removed loginIsUtilLinux field and detection call
client/ssh/server/executor_unix_test.go Updated tests to use current user credentials and added PTY test coverage
client/ssh/server/executor_unix.go Implemented PTY support with controlling terminal setup and exec syscall
client/ssh/server/command_execution_windows.go Removed detectUtilLinuxLogin stub function
client/ssh/server/command_execution_unix.go Removed detectUtilLinuxLogin function and runtime import
client/ssh/server/command_execution_js.go Removed detectUtilLinuxLogin stub function
client/cmd/ssh_exec_unix.go Updated flag help text to remove "will fail" message

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread client/ssh/server/executor_unix.go
Comment thread client/ssh/server/executor_unix.go Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread client/ssh/server/executor_unix.go Outdated
Comment thread client/ssh/server/executor_unix.go Outdated
Comment on lines +228 to +248
if config.PTY {
if err := pd.prepareControllingTerminal(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "PTY setup failed: %v\n", err)
os.Exit(ExitCodeShellExecFail)
}

argv := []string{"-" + filepath.Base(config.Shell)}
if config.Command == "" {
log.Tracef("executing login shell via exec: %s", config.Shell)
} else {
argv = append(argv, "-c", config.Command)
cmdParts := strings.Fields(config.Command)
safeCmd := safeLogCommand(cmdParts)
log.Tracef("executing %s -c %s via exec", config.Shell, safeCmd)
}

if err := syscall.Exec(config.Shell, argv, os.Environ()); err != nil {
log.Debugf("exec failed: %v", err)
os.Exit(ExitCodeShellExecFail)
}
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

The new PTY execution path (prepareControllingTerminal + syscall.Exec) isn’t covered by tests in this package. Since behavior differs significantly from the non-PTY path, consider adding an integration-style test (similar to TestPrivilegeDropper_ActualPrivilegeDrop’s child-process pattern) that runs ssh exec --pty under a PTY and asserts it provides an interactive shell and preserves exit codes.

Copilot uses AI. Check for mistakes.
Comment thread client/ssh/server/userswitching_unix.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

Removes util‑linux login detection and associated wrapping across SSH server files; adds PTY-support path in the Unix privilege‑drop executor (preparing a controlling terminal and exec’ing the shell); updates tests to use dynamic UID/GID and tightens CLI --pty help text.

Changes

Cohort / File(s) Summary
Util‑Linux Detection Removal
client/ssh/server/command_execution_js.go, client/ssh/server/command_execution_unix.go, client/ssh/server/command_execution_windows.go, client/ssh/server/server.go
Removed detectUtilLinuxLogin implementations and removed loginIsUtilLinux usage from Server startup.
Unix Executor PTY & Privilege Drop
client/ssh/server/executor_unix.go
Adds PTY execution path in privilege dropper: prepareControllingTerminal() (uses setsid/getsid and ioctl TIOCSCTTY via golang.org/x/sys/unix), updated privilege validation, and exec error handling.
Tests Updated for Dynamic UIDs
client/ssh/server/executor_unix_test.go
Refactors tests to derive current UID/GID at runtime and adds TestPrivilegeDropper_CreateExecutorCommandWithPTY verifying PTY flags.
User‑switching Simplification
client/ssh/server/userswitching_unix.go
Removed setsid-based wrapping/fallback for util‑linux login; getLinuxLoginCmd now returns login path and args directly.
CLI Help Text
client/cmd/ssh_exec_unix.go
Changed --pty flag help string to a concise label (user-facing help text only).

Sequence Diagram

sequenceDiagram
    participant Client
    participant ServerExec as "Unix Executor"
    participant PTY as "PTY/Terminal"
    participant Kernel as "OS Syscall"

    Client->>ServerExec: Request command exec (with PTY, privilege drop)
    ServerExec->>ServerExec: Validate privilege transition
    alt PTY requested
        ServerExec->>PTY: prepareControllingTerminal()
        PTY->>Kernel: setsid()
        alt EPERM (already session leader)
            Kernel-->>PTY: EPERM
            PTY->>Kernel: getsid()
            Kernel-->>PTY: session id
        else Success
            Kernel-->>PTY: session created
        end
        PTY->>Kernel: ioctl(TIOCSCTTY)
        alt Success
            Kernel-->>PTY: controlling tty set
        else ENOTTY/EINVAL
            Kernel-->>PTY: error (log and continue)
        end
        PTY-->>ServerExec: terminal ready (or continued)
    end
    ServerExec->>Kernel: syscall.Exec(target shell/command)
    Kernel-->>Client: process replaced / command started
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • pappz
  • pascal-fischer

"I hopped through code with tiny feet,
removed the dance where login and setsid meet.
I built a terminal, snug and spry,
so shells can run and users fly.
🐰✨"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR successfully addresses all coding requirements from issue #5345: implements in-executor PTY setup via prepareControllingTerminal with setsid/TIOCSCTTY logic, removes external setsid dependency, replaces detectUtilLinuxLogin calls, and updates privilege-dropping logic to support PTY-enabled execution.
Out of Scope Changes check ✅ Passed All code changes are directly related to the stated objectives: PTY flag help text update, method removals related to util-linux detection, executor PTY support implementation, and test updates to validate the new functionality. No extraneous changes detected.
Title check ✅ Passed The title accurately summarizes the main changes: implementing PTY support in executor and removing setsid wrapper, which directly addresses the stated objectives.
Description check ✅ Passed The description is minimal but addresses the core points: references the issue number (#5345), confirms testing, and explains why documentation isn't needed. However, it deviates from the template structure by not completing the full checklist or providing issue link.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
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.

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

78-81: Minor duplication: UID/GID setup repeated across three tests.

currentUID/currentGID (and the string conversions) appear identically in every test. A small shared helper or table-driven approach would reduce the noise.

♻️ Example helper extraction
+func currentUIDGID(t *testing.T) (uid, gid uint32, uidStr, gidStr string) {
+	t.Helper()
+	uid = uint32(os.Geteuid())
+	gid = uint32(os.Getegid())
+	uidStr = strconv.FormatUint(uint64(uid), 10)
+	gidStr = strconv.FormatUint(uint64(gid), 10)
+	return
+}

Then in each test:

-	currentUID := uint32(os.Geteuid())
-	currentGID := uint32(os.Getegid())
-	uidStr := strconv.FormatUint(uint64(currentUID), 10)
-	gidStr := strconv.FormatUint(uint64(currentGID), 10)
+	currentUID, currentGID, uidStr, gidStr := currentUIDGID(t)

Also applies to: 115-116, 138-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ssh/server/executor_unix_test.go` around lines 78 - 81, The tests in
executor_unix_test.go duplicate the currentUID/currentGID retrieval and string
conversion logic across multiple test cases (variables currentUID/currentGID and
their strconv.Itoa conversions); extract that into a small helper (e.g.,
getCurrentUIDGID() or a test helper that returns uid,gid and uidStr,gidStr) and
call it from each test (or convert the tests to a table-driven loop that uses
the helper) so the duplicated setup is centralized and reused.

136-157: --cmd absence doesn't exercise PTY-specific behaviour; consider a second sub-case.

Command: "" already implies --cmd will be absent — the assert.NotContains(t, cmd.Args, "--cmd") assertion passes trivially without any PTY logic involved.
A more meaningful coverage addition would be a case where PTY: true and Command: "ls -la" to verify that --pty is still emitted and --cmd is also present (i.e., both flags coexist when a command is supplied in PTY mode).

🧪 Suggested additional sub-case
  assert.Contains(t, cmd.Args, "--pty")
  assert.NotContains(t, cmd.Args, "--cmd")
+
+ // PTY with an explicit command: both --pty and --cmd should appear.
+ configWithCmd := ExecutorConfig{
+ 	UID:        currentUID,
+ 	GID:        currentGID,
+ 	Groups:     []uint32{currentGID},
+ 	WorkingDir: "/home/testuser",
+ 	Shell:      "/bin/bash",
+ 	Command:    "ls -la",
+ 	PTY:        true,
+ }
+ cmdWithCmd, err := pd.CreateExecutorCommand(context.Background(), configWithCmd)
+ require.NoError(t, err)
+ require.NotNil(t, cmdWithCmd)
+ assert.Contains(t, cmdWithCmd.Args, "--pty")
+ assert.Contains(t, cmdWithCmd.Args, "--cmd")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ssh/server/executor_unix_test.go` around lines 136 - 157, Add a new
sub-case in the existing test in executor_unix_test.go that sets PTY: true and
Command: "ls -la" (instead of Command: ""), call the same code path that builds
the cmd (referencing the local variable cmd and the existing test
helper/assertions), and assert that cmd.Args contains "--pty", contains "--cmd",
and that the provided command string (or its split tokens) appears after the
"--cmd" flag; this verifies PTY-specific behavior when a command is supplied
rather than relying on the trivial empty-Command case.
client/ssh/server/executor_unix.go (1)

284-319: Robust controlling-terminal setup with good container tolerance.

The setsid + TIOCSCTTY pattern is the standard way to establish a controlling terminal, and the EPERM-on-setsid handling (verifying we're already a session leader before continuing) is a nice touch.

One observation on lines 308-311: EPERM from TIOCSCTTY is treated as a benign container/seccomp condition, but EPERM can also mean the terminal is already a controlling terminal of a different session. In the SSH privilege-drop context this is very unlikely (the PTY is freshly allocated for this session), so this trade-off seems acceptable. If you want to tighten it up, you could log the EPERM at a slightly higher level (Info vs Debug) to make debugging easier if it does mask a real issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/ssh/server/executor_unix.go` around lines 284 - 319, In
prepareControllingTerminal, when unix.IoctlSetInt(stdinFD, unix.TIOCSCTTY, 0)
returns EPERM the code currently logs with pd.log().Debugf; change that call to
a higher-severity log (e.g., pd.log().Infof or pd.log().Warnf) and keep the same
message and error payload so container/seccomp denials remain tolerated but
EPERM is more visible for debugging; ensure you only change the log level in the
EPERM branch that follows unix.IoctlSetInt and leave other error-handling
branches untouched.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/ssh/server/executor_unix_test.go`:
- Around line 78-81: The tests in executor_unix_test.go duplicate the
currentUID/currentGID retrieval and string conversion logic across multiple test
cases (variables currentUID/currentGID and their strconv.Itoa conversions);
extract that into a small helper (e.g., getCurrentUIDGID() or a test helper that
returns uid,gid and uidStr,gidStr) and call it from each test (or convert the
tests to a table-driven loop that uses the helper) so the duplicated setup is
centralized and reused.
- Around line 136-157: Add a new sub-case in the existing test in
executor_unix_test.go that sets PTY: true and Command: "ls -la" (instead of
Command: ""), call the same code path that builds the cmd (referencing the local
variable cmd and the existing test helper/assertions), and assert that cmd.Args
contains "--pty", contains "--cmd", and that the provided command string (or its
split tokens) appears after the "--cmd" flag; this verifies PTY-specific
behavior when a command is supplied rather than relying on the trivial
empty-Command case.

In `@client/ssh/server/executor_unix.go`:
- Around line 284-319: In prepareControllingTerminal, when
unix.IoctlSetInt(stdinFD, unix.TIOCSCTTY, 0) returns EPERM the code currently
logs with pd.log().Debugf; change that call to a higher-severity log (e.g.,
pd.log().Infof or pd.log().Warnf) and keep the same message and error payload so
container/seccomp denials remain tolerated but EPERM is more visible for
debugging; ensure you only change the log level in the EPERM branch that follows
unix.IoctlSetInt and leave other error-handling branches untouched.

pieterhollander and others added 2 commits February 18, 2026 15:20
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@sonarqubecloud
Copy link
Copy Markdown

@pieterhollander pieterhollander changed the title ssh: implement PTY support in executor and remove setsid wrapper [client] ssh: implement PTY support in executor and remove setsid wrapper Feb 18, 2026
@pieterhollander pieterhollander changed the title [client] ssh: implement PTY support in executor and remove setsid wrapper [client] ssh: fix #5345 by implementing PTY support in executor and removing setsid wrapper Feb 18, 2026
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.

netbird ssh fails in unprivileged LXC/Incus containers: setsid: failed to set the controlling terminal: Operation not permitted

3 participants