Add a long-lived elevated channel to Executor (#132) - #135
Conversation
bootler's recorded recovery runs every operation for a host over one framed session to a root helper, and needs that helper started locally or over SSH as a long-lived child with piped stdio, under this crate's SSH and elevation policy. run and run_with_input return only once the command exits, and ssh_command and resolve_through are private, so nothing public could do it. open_channel resolves an identity exactly as run does, at the same resolution site, proves the start by reading the sudo sentinel before returning, and hands the caller stdin and stdout. Stderr is drained on a joined, poll-driven thread for the channel's whole life, so the command never blocks on it, and over SSH the exit-status line supplies the command's own code. InDaemonExecutor keeps the default Unsupported body, and the recording executor records and scripts the call. Closes #132
STDERR_GRACE is read only inside the channel module, so it no longer needs to be visible to its parent, and START_SHELL now sits with the module's other constants at the top of the file. settle_start takes the password with a let-else rather than testing for None and then unwrapping a default it could never reach. Part of #132
The test's `/bin/sleep 8` held standard error open past the grace and was then left to run out on its own, outliving the test. It now records its pid, and the test kills it and waits for it to be gone once the channel has returned. Part of #132
Two points of the open_channel contract had no test: wait closes a standard input the caller never took, so a command reading to end of input still ends, and a local transport ended by a signal reports no code rather than an invented one, while over SSH the remote shell's status stands. Part of #132
|
[Reviewer Round 1] Request changes. The implementation follows the existing resolution paths and covers the main channel behavior, but two requirements remain unmet:
The supplied PR body has the required |
|
[Review Verdict Round 1: NOT_APPROVED] |
Under SudoAuth::Password a sudo that does not ask for the password -- a NOPASSWD rule, or cached credentials -- left the password line unread, and the command read it ahead of the caller's first byte. The start script now reads standard input up to a handoff line carrying a random nonce, written only once the sentinel has arrived and sudo is done with stdin, and discards whatever precedes it before exec'ing the command. The start loop also found the sentinel before checking the 64 KiB preamble limit, so a transport that flooded past the limit and then printed the sentinel in the same read was accepted. What precedes the sentinel is now held to the limit wherever the sentinel lands. Part of #132
|
[Author Round 1] I fixed both Round 1 items and pushed commit 1. The password could reach the command as input: Fixed.
2. A late sentinel got past the 64 KiB limit: Fixed.
I checked that the new tests fail when each fix is removed, then restored the code. PR description: I updated how the start works and added the new tests to the test plan. I also unticked "CI green" until the new run passes. Nothing now departs from the issue, so "Deviations from the issue" stays |
|
[Reviewer Round 2] [Reviewer Round 2] — Request changes. Both Round 1 items are resolved in the updated diff: the handoff prevents an unread sudo password from reaching the command, and the preamble limit is checked at the sentinel’s position. One new finding remains. In Channel::wait, the SSH branch accepts a parsed stderr marker without checking the local The PR’s |
|
[Review Verdict Round 2: NOT_APPROVED] |
The SSH wrapper prints the exit-status line as its last act and so exits the remote shell zero. A local ssh that exited unsuccessfully therefore never delivered that line: anything shaped like it at the end of stderr was written by the command itself before the connection dropped. Accepting it would hand bootler a zero it treats as proof that cleanup succeeded. Such a channel now ends in ExitUnknown. Part of #132
|
[Author Round 2] I fixed the one item Reviewer Round 2 raised and pushed it as commit Trusting a status line from a failed
Locally, fmt, both clippy runs, CI's |
|
[Reviewer Round 3] [Reviewer Round 3] — Approve. The Round 2 finding is resolved. Channel::wait now accepts the SSH status marker only when the local One PR body checkbox is stale: “CI green” remains unchecked despite the controller’s report that CI passed. |
|
[Review Verdict Round 3: APPROVED] |
Suggested squash commitTitle Body |
Summary
Adds
Executor::open_channel, a long-running counterpart torun_with_input. It starts a command as a given identity, locally or over SSH, and returns aChannel: the caller gets the command's stdin and stdout for as long as the command runs, and at the end gets the command's own exit code. It uses the crate's existing SSH and elevation settings, so bootler can start its root helper without building anysshorsudocommand line itself.ChannelLimits,Channel(take_stdin,take_stdout,wait,kill),ChannelExitandChannelError. The logic lives in a new privatesrc/executor/channel.rs.run:LocalExecutorandSshExecutorbuild the command through the same privateresolve_throughandssh_commandasrun. No flag is added or removed, and no-tis requested. The transport runs in the caller's process group, so a passphrase prompt allowed bySshPrompt::Allowstill reaches the terminal.sudoor SSH is involved, a fixedsh -cscript printsSUDO_OK_SENTINELon stderr, reads stdin up to a handoff line, and thenexecs/usr/bin/env -i <command> <args…>.open_channelreturns only after it has read the sentinel. Any password line is written first, and stdout is never read.sudois done with stdin. Whatever precedes it is discarded, so a password line thatsudonever read (aNOPASSWDrule, or cached credentials) does not reach the command, and the command's first stdin byte is always the caller's first byte.runclassifies it (Connection,ElevationorSudoRefused).elevation_timeoutpasses is killed and reaped, andElevationTimedOutis returned.polland non-blocking reads, and is always joined. It keeps the firstmax_stderrbytes of the command's own stderr. Over SSH it removes the trailing exit-status line and takes the exit code from it, so a remote255is the command's code. The line is trusted only whensshitself exits 0, since the wrapper exits 0 once it has printed it. If the line is missing, orsshfailed (so a status-shaped line can only be the command's own stderr),waitreturnsExitUnknown, not a guessed code.waitcloses stdin if the caller never took it, waits for the transport, then waits at most 5 seconds for stderr to end.kill, and dropping a channel that was neither waited for nor killed, close the pipes the channel still holds,SIGKILLthe local transport process and reap it. The rustdoc states the limit: a command behindsudoor SSH is not signalled directly and sees the end only as EOF on stdin andEPIPEon stdout.Unsupported, so external implementors keep compiling.InDaemonExecutorkeeps that default.test-support:RecordingExecutorrecordsRecordedCall::OpenChannel. It answers fromScriptedChannel::Spawn, which runs a real program directly as theChannel, or fromScriptedChannel::Fail. An invalid command is recorded and refused without using up a script entry.ExecutorErrorvariant or behaviour changes.bounded.rsonly widens a few private helpers topub(super)so the channel module can reuse them. There is no new dependency, no newunsafe, nopre_exec, and noCHANGELOG.mdentry.Closes #132
Deviations from the issue
None
Test plan
cargo fmt -- --check --config group_imports=StdExternalCratecargo clippy --all-targets -- -D warningscargo clippy --all-targets --features test-support -- -D warningsRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --features test-supportcargo testandcargo test --features test-supportubuntu-24.04-armandmacos-latestjobs/bin/catechoes a 256 KiB pattern of every byte value exactly on the local and SSH operator, root and service pairs;waitgivesSome(0)with empty stderr (bytes_pass_verbatim_both_ways_on_every_pair)stderris exactlymax_stderrbytes andstderr_truncatedis set (stderr_is_drained_and_held_to_its_limit_while_the_echo_runs_on_every_pair)0,3and255come back as the command's own code on every pair,255over SSH included;stderrcontains neither the sentinel nor the exit-status line (the_commands_own_code_and_stderr_come_back_on_every_pair)Nonelocally, where the signal ends the local transport, and the remote shell's137over SSH (a_local_transport_ended_by_a_signal_has_no_code)waitcloses stdin the caller never took, so/bin/catsees end of input on every pair (wait_closes_standard_input_that_was_never_taken_on_every_pair)run: a recordingsudostub shows the same words ahead of the shell asrunfor local and SSH root and service, underNonInteractiveandPassword; the operator runs with nosudo(identities_resolve_exactly_as_run_resolves_them)sshstub shows the samesshprefix asrun, withBatchMode=yesexactly underSshPrompt::Denyand no-t(the_ssh_invocation_is_run_s_with_no_terminal)the_start_consumes_no_standard_output)SudoAuth::Password,/bin/catechoes the caller's bytes and not the password, locally and over SSH, both withpassword_sudo(which reads the password) and with asudostub that never reads it, as underNOPASSWD(a_password_line_is_consumed_before_the_callers_bytes)ElevationunderNonInteractive, "not in the sudoers file" givesSudoRefused, and a failingsshstub givesConnection(failures_before_the_start_are_reported_as_run_reports_them)sudostub that never prints the sentinel givesElevationTimedOutat a 200 mselevation_timeout, and its pid is gone afterwards (a_start_not_proven_in_time_is_killed_and_reaped)-Spassword times out with sudo's "Sorry, try again" in the diagnostic (a_rejected_password_times_out_with_sudos_complaint)a_transport_that_floods_stderr_before_the_start_is_killed_and_classified)SudoRefused, end to end (a_start_at_the_transport_limit_opens_and_one_past_it_is_refused); deterministically, a sentinel arriving in the same read that passes the limit is refused with only the transport's bytes kept, a trailing fragment of the sentinel is not yet counted, and the start waits for the password to be written in full (the_transport_limit_holds_wherever_the_sentinel_lands,the_start_waits_for_the_password_to_be_written_in_full)/usr/bin/envprints nothing on every pair (the_command_sees_an_empty_environment_on_every_pair)sshstub that prints the sentinel, echoes, and exits without the exit-status line givesExitUnknown(an_ssh_channel_that_loses_its_exit_status_has_no_code)sshstub whose command's stderr ends in a well-formed exit-status line for0, then exits 255, givesExitUnknown, notSome(0)(a_failed_ssh_is_not_trusted_for_a_status_line_the_command_forged)kill, and separately after drop, the transport pid is gone, and a command that is not the transport sees end of input and exits (kill_and_drop_end_the_transport_and_the_command_sees_eof_on_every_pair)waitreturns within the 5-second grace withstderr_truncatedset (wait_gives_stderr_a_grace_when_a_descendant_holds_it)catand/bin/a=bgiveInvalidCommandwith nothing spawned (a_command_that_is_not_an_absolute_path_is_refused_before_spawning)InDaemonExecutorreturnUnsupported(the_default_body_and_the_daemon_are_unsupported)Spawnof/bin/catechoes through theChanneland the call is recorded;Failis returned; an invalid command is recorded and uses up no script entry (a_scripted_spawn_is_a_channel_to_that_program,a_scripted_failure_is_returned_and_an_invalid_command_consumes_nothing)the_status_line_is_read_only_at_the_end_of_the_stream,a_sink_keeps_its_limit_and_removes_the_status_line)