Skip to content

fix(ssh): perform root command elevation test once before password input#204

Merged
water-sucks merged 2 commits intonix-community:mainfrom
water-sucks:allow-passwordless-remote-cmd
Apr 1, 2026
Merged

fix(ssh): perform root command elevation test once before password input#204
water-sucks merged 2 commits intonix-community:mainfrom
water-sucks:allow-passwordless-remote-cmd

Conversation

@water-sucks
Copy link
Copy Markdown
Collaborator

@water-sucks water-sucks commented Mar 9, 2026

There is a litany of bugs that exist with current root elevation mechanisms.

  • Commands that require input (i.e. sudo invocations) often dispose of the first character; this only applies to the second command ran with a single SSH client and subsequent ones, and makes password input for them very messy.
  • When a user was configured to have passwordless sudo/doas elevation, attempting to activate with that user in nixos apply --target-host was broken since it would always ask for a password when EnsureRemoteRemotePassword was called, thus making it non-interactive.

This PR solves issues like these by making the root elevation mechanism much more consistent, and by exposing more behavior to the user through settings.

Now, every root command can either choose to:

  • Take the password as input once, and then pass it using stdin along with a newline. The command must support it; i.e. sudo can take passwords on stdin, and this is the default settings.
  • Force using a TTY whenever possible; this only really applies to running commands over SSH, and leaves local root elevation virtually unchanged.
  • Do not allow any input, and immediately fail if interactive input is requested. This only works if a user is configured with NOPASSWD in sudo, or nopass in doas, etc.

Summary by CodeRabbit

  • New Features

    • Added structured root privilege escalation configuration with root.command and root.password_method settings
    • Introduced support for multiple password input methods: stdin, TTY, and none
  • Bug Fixes

    • Improved password prompting behavior and error handling for root elevation operations
  • Chores

    • Deprecated legacy root_command setting in favor of new nested root configuration structure

@water-sucks water-sucks force-pushed the allow-passwordless-remote-cmd branch from 76de293 to 37104fe Compare March 11, 2026 06:53
@water-sucks water-sucks force-pushed the allow-passwordless-remote-cmd branch 4 times, most recently from 775ec76 to 8a99a36 Compare April 1, 2026 20:33
@water-sucks
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

📝 Walkthrough

Walkthrough

This PR refactors root command elevation from simple string fields to a new RootElevator abstraction that encapsulates command, flags, and password-handling methods. Changes include introducing RootElevator type in a new internal/system/elevator.go, updating settings with nested root configuration structure, refactoring activation and system runners to use the elevator, modifying SSH system password handling, and adding password prompt and username utilities.

Changes

Cohort / File(s) Summary
Settings & Configuration
internal/settings/settings.go
Extends settings with nested root configuration supporting command and password_method fields; retains top-level RootCommand as deprecated with validation error and migration logic; adds PasswordInputMethod constants and RootCommandSettings struct.
Root Elevation Abstraction
internal/system/elevator.go
New file introducing RootElevator type with command, flags, and password provider; implements PasswordProvider interface with CachedPasswordProvider; exposes ElevatorFromConfig() and PromptIfNecessary() for configurable root elevation with method-specific flag handling.
Command Execution & Runner
internal/system/runner.go, internal/system/runner_test.go
Refactors Command to use internal rootElevator instead of exposed elevation fields; updates AsRoot() signature to accept *RootElevator; removes Clone() method; adjusts shell wrapper and argument building to use elevator's command and flags.
Local System Implementation
internal/system/local.go
Integrates RootElevator for stdin handling; routes password based on PasswordInputMethod (stdin/tty/none); replaces direct RootElevationCmd checks with elevator presence validation.
SSH System Implementation
internal/system/ssh.go
Removes rootPassword state and pre-flight password verification; refactors stdin handling for root elevation to use new elevator abstraction; improves PTY raw-mode restoration; replaces inline password prompt with utils.PromptForPassword(); adds stdin workaround for /dev/stdin.
Activation Logic
internal/activation/activation.go
Replaces RootCommand string option fields with RootElevator *system.RootElevator in AddNewNixProfileOptions, SetNixProfileGenerationOptions, and SwitchToConfigurationOptions; updates callers to pass elevator to cmd.AsRoot().
CLI Commands
cmd/apply/apply.go
Threads RootElevator through channel upgrading and activation pathways; adds conditional elevator creation from config; updates password prompting to use rootElevator.PromptIfNecessary(); changes re-exec-as-root to use cfg.Root.Command.
Generation Commands
cmd/generation/delete/delete.go, cmd/generation/rollback/rollback.go, cmd/generation/switch/switch.go
Updates root re-exec paths to use cfg.Root.Command instead of cfg.RootCommand.
Utilities
internal/utils/utils.go
Adds PromptForPassword() for TTY-aware password input with context cancellation support; adds GetUsername() using os/user.Current() with environment fallback.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Suggested reviewers

  • Sporif
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title describes fixing root command elevation testing before password input, which is addressed by the refactoring from EnsureRemoteRootPassword to the new RootElevator abstraction and conditional password prompting.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.11.4)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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

@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)
cmd/apply/apply.go (1)

314-323: ⚠️ Potential issue | 🟠 Major

Re-exec still ignores root.password_method.

ExecAsRoot(cfg.Root.Command) only prefixes the bare command, so password_method = none never adds -n, and the new stdin/TTY modes are skipped entirely. That leaves apply.reexec_as_root—and the same helper used by generation delete/switch/rollback—outside the new RootElevator behavior.

Possible direction
-			if err = utils.ExecAsRoot(cfg.Root.Command); err != nil {
+			elevator, elevErr := system.ElevatorFromConfig(&cfg.Root)
+			if elevErr != nil {
+				log.Error(elevErr)
+				return elevErr
+			}
+			if err = utils.ExecAsRoot(elevator.Command, elevator.Flags...); err != nil {
 				log.Errorf("failed to re-exec command as root: %v", err)
 				return err
 			}
This needs `ExecAsRoot` to accept variadic extra argv entries.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/apply/apply.go` around lines 314 - 323, The re-exec path uses
ExecAsRoot(cfg.Root.Command) which ignores cfg.Root.PasswordMethod and new
RootElevator behavior, so update ExecAsRoot to accept variadic extra argv
entries (e.g., ExecAsRoot(cmd string, extra ...string)) and propagate the
configured flags/TTY/stdin options when building the final argv; then change
callers (like the re-exec in apply.go where ExecAsRoot is invoked) to pass the
appropriate extra arguments (such as "-n" or other elevator-specific tokens)
derived from cfg.Root and RootElevator so password_method and TTY handling are
honored by ExecAsRoot and reuse the same helper across apply/generate
delete/switch/rollback.
🧹 Nitpick comments (4)
internal/system/runner_test.go (1)

98-107: Cover the new stdin-handling modes too.

This only checks argv prefixing. The new behavior is in internal/system/local.go for PasswordInputMethodStdin and PasswordInputMethodNone, so a regression there would still pass this test.

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

In `@internal/system/runner_test.go` around lines 98 - 107, The current
TestBuildShellWrapper_WithRootElevation only verifies argv prefixing; extend it
to also exercise the stdin-handling modes defined in internal/system/local.go by
adding sub-tests for PasswordInputMethodStdin and PasswordInputMethodNone using
the same RootElevator and NewCommand/AsRoot flow: for PasswordInputMethodStdin
assert the resulting wrapped command is configured to read from stdin (e.g.,
expects a pipe or includes the stdin flag/marker your wrapper uses), and for
PasswordInputMethodNone assert it does not require stdin and that no stdin
piping/marker is present; reference the RootElevator struct,
PasswordInputMethodStdin, PasswordInputMethodNone, NewCommand, AsRoot and the
TestBuildShellWrapper_WithRootElevation test to locate and add these assertions.
internal/settings/settings.go (1)

91-97: Consider adding UnmarshalText for PasswordInputMethod for robust parsing.

Unlike DiffTool and ConfirmationPromptBehavior which have UnmarshalText methods, PasswordInputMethod relies on direct string assignment. Invalid values like "invalid" would be silently accepted during config parsing.

🔧 Add UnmarshalText for validation
func (p *PasswordInputMethod) UnmarshalText(text []byte) error {
	v := PasswordInputMethod(text)
	switch v {
	case PasswordInputMethodStdin, PasswordInputMethodTTY, PasswordInputMethodNone:
		*p = v
		return nil
	default:
		return fmt.Errorf("invalid value for password_method '%s'", text)
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/settings/settings.go` around lines 91 - 97, Add an UnmarshalText
method to PasswordInputMethod to validate parsed values instead of accepting
arbitrary strings: implement func (p *PasswordInputMethod) UnmarshalText(text
[]byte) error that converts text to PasswordInputMethod, checks it against the
allowed constants PasswordInputMethodStdin, PasswordInputMethodTTY, and
PasswordInputMethodNone, sets *p on success, and returns a descriptive error
(e.g. "invalid value for password_method '%s'") for any other value so config
parsing fails on invalid inputs.
internal/system/elevator.go (2)

81-107: Consider zeroing the password after use for defense in depth.

The CachedPasswordProvider stores the password as a plain string in memory. While this is often acceptable for short-lived CLI tools, zeroing the password after the process completes could reduce the window for memory scraping attacks.

This is a low-priority security hardening suggestion given the CLI's typical usage pattern.

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

In `@internal/system/elevator.go` around lines 81 - 107, CachedPasswordProvider
currently retains the password in memory; add a Clear (or Dispose) method on
CachedPasswordProvider that securely zeroes the stored password (convert the
string to a byte slice, overwrite bytes with zeros, then set p.password = "" and
p.set = false) and ensure callers that finish using the password invoke this
Clear method; update any code paths that call GetPassword/PromptForPassword to
call Clear after elevation completes to reduce exposure time.

71-75: Consider logging the error from GetUsername() for debugging.

The error from utils.GetUsername() is silently discarded. While the fallback prompt is reasonable, logging at debug level would help troubleshoot issues.

🔧 Suggested improvement
-	if currentUser, _ := utils.GetUsername(); currentUser != "" {
+	if currentUser, err := utils.GetUsername(); err == nil && currentUser != "" {
 		prompt = fmt.Sprintf("[%s] password for %s: ", e.Command, currentUser)
 	} else {
+		// Debug log: failed to get username, using generic prompt
 		prompt = fmt.Sprintf("[%s] enter password: ", e.Command)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/system/elevator.go` around lines 71 - 75, Capture and check the
error returned from utils.GetUsername() instead of discarding it, and log it at
debug level for troubleshooting; modify the block using utils.GetUsername()
(where currentUser is assigned and prompt is set) to use "currentUser, err :=
utils.GetUsername()" and if err != nil call the elevator's logger (e.g.,
e.Logger.Debugf("utils.GetUsername error: %v", err)) before falling back to the
existing prompt logic that uses e.Command and prompt.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/apply/apply.go`:
- Around line 356-363: The rootElevator is only created when os.Geteuid() != 0
which can leave rootElevator nil while activationUseRoot (the flag deciding
remote/local elevation) is true; change the creation logic so that you call
system.ElevatorFromConfig(&cfg.Root) and set rootElevator whenever elevation is
requested (i.e., when activationUseRoot or the equivalent flag/condition for
remote/root elevation is true), not just when Geteuid() != 0; ensure you still
handle and return the error from system.ElevatorFromConfig(&cfg.Root) the same
way as before so activation paths that call AsRoot(...) have a non-nil elevator
when UseRootCommand is true.

In `@internal/utils/utils.go`:
- Around line 142-176: PromptForPassword currently reads from stdin so the
goroutine blocks even if ctx is cancelled; change it to open and read from a
cancellable descriptor (e.g. os.Open("/dev/tty")) and pass that fd to
term.ReadPassword so you can close the file when ctx.Done() fires; ensure you
call term.Restore(fd, oldState) and close the /dev/tty file on cancel, and still
restore and close on normal completion; update references in the function
(PromptForPassword, term.ReadPassword, term.Restore) accordingly so the
goroutine stops when the context is cancelled.

---

Outside diff comments:
In `@cmd/apply/apply.go`:
- Around line 314-323: The re-exec path uses ExecAsRoot(cfg.Root.Command) which
ignores cfg.Root.PasswordMethod and new RootElevator behavior, so update
ExecAsRoot to accept variadic extra argv entries (e.g., ExecAsRoot(cmd string,
extra ...string)) and propagate the configured flags/TTY/stdin options when
building the final argv; then change callers (like the re-exec in apply.go where
ExecAsRoot is invoked) to pass the appropriate extra arguments (such as "-n" or
other elevator-specific tokens) derived from cfg.Root and RootElevator so
password_method and TTY handling are honored by ExecAsRoot and reuse the same
helper across apply/generate delete/switch/rollback.

---

Nitpick comments:
In `@internal/settings/settings.go`:
- Around line 91-97: Add an UnmarshalText method to PasswordInputMethod to
validate parsed values instead of accepting arbitrary strings: implement func (p
*PasswordInputMethod) UnmarshalText(text []byte) error that converts text to
PasswordInputMethod, checks it against the allowed constants
PasswordInputMethodStdin, PasswordInputMethodTTY, and PasswordInputMethodNone,
sets *p on success, and returns a descriptive error (e.g. "invalid value for
password_method '%s'") for any other value so config parsing fails on invalid
inputs.

In `@internal/system/elevator.go`:
- Around line 81-107: CachedPasswordProvider currently retains the password in
memory; add a Clear (or Dispose) method on CachedPasswordProvider that securely
zeroes the stored password (convert the string to a byte slice, overwrite bytes
with zeros, then set p.password = "" and p.set = false) and ensure callers that
finish using the password invoke this Clear method; update any code paths that
call GetPassword/PromptForPassword to call Clear after elevation completes to
reduce exposure time.
- Around line 71-75: Capture and check the error returned from
utils.GetUsername() instead of discarding it, and log it at debug level for
troubleshooting; modify the block using utils.GetUsername() (where currentUser
is assigned and prompt is set) to use "currentUser, err := utils.GetUsername()"
and if err != nil call the elevator's logger (e.g.,
e.Logger.Debugf("utils.GetUsername error: %v", err)) before falling back to the
existing prompt logic that uses e.Command and prompt.

In `@internal/system/runner_test.go`:
- Around line 98-107: The current TestBuildShellWrapper_WithRootElevation only
verifies argv prefixing; extend it to also exercise the stdin-handling modes
defined in internal/system/local.go by adding sub-tests for
PasswordInputMethodStdin and PasswordInputMethodNone using the same RootElevator
and NewCommand/AsRoot flow: for PasswordInputMethodStdin assert the resulting
wrapped command is configured to read from stdin (e.g., expects a pipe or
includes the stdin flag/marker your wrapper uses), and for
PasswordInputMethodNone assert it does not require stdin and that no stdin
piping/marker is present; reference the RootElevator struct,
PasswordInputMethodStdin, PasswordInputMethodNone, NewCommand, AsRoot and the
TestBuildShellWrapper_WithRootElevation test to locate and add these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a695fb03-0001-4979-b4d9-54eee9893419

📥 Commits

Reviewing files that changed from the base of the PR and between 23e7540 and 8a99a36.

📒 Files selected for processing (12)
  • cmd/apply/apply.go
  • cmd/generation/delete/delete.go
  • cmd/generation/rollback/rollback.go
  • cmd/generation/switch/switch.go
  • internal/activation/activation.go
  • internal/settings/settings.go
  • internal/system/elevator.go
  • internal/system/local.go
  • internal/system/runner.go
  • internal/system/runner_test.go
  • internal/system/ssh.go
  • internal/utils/utils.go

@water-sucks water-sucks force-pushed the allow-passwordless-remote-cmd branch from 8a99a36 to 237fcbf Compare April 1, 2026 21:21
@water-sucks water-sucks merged commit b67cc8c into nix-community:main Apr 1, 2026
2 checks passed
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.

1 participant