Add profile-applying descent commands to the daemon (#133) - #136
Conversation
bootler's session helper is a root process that spawns, bounds and kills its own children, and must run them as a service account or as the operator its session authenticated, under a pinned environment and working directory. InDaemonExecutor could do neither: its resolution was private, it applied no profile, and it refuses Identity::Operator. descent_command builds, without spawning, a Command that descends through sudo from the same site run uses, then applies the profile as the target identity: cd into the directory, announce the start, and exec env -i with the K=V words. Every value stays a discrete argv word. The operator goes through sudo -u #uid -g #gid rather than CommandExt::uid, which would drop supplementary groups and need new unsafe to restore them. settle_descent classifies the child's stderr. Its sentinel search and 64 KiB transport limit are factored out of the channel's judge, so both starts are settled by one rule. Also switch one test Duration to from_mins, which clippy on the current stable toolchain requires. Closes #133
Under a controlling terminal, sudo with use_pty -- the Debian and Ubuntu default -- runs the command in a session of its own, so the process-group check fails for a reason that is the test's environment and not the descent. That is exactly the condition descent_command documents, but the test's instructions said to run it from a shell. Say so, suggest setsid -w, and refuse up front with that advice rather than failing on the group assertion. Part of #133
Stock `nobody` has only its primary group, so the ignored real-sudo test's groups assertion cannot tell group-database groups apart from a descent that dropped them all -- the very property `sudo` was chosen over `CommandExt::uid` for. Say how to make a run that shows them. Part of #133
|
[Reviewer Round 1] Verdict: Request changes. The builder uses the intended
The other declared deviations—capping and trimming the reason, deriving |
|
[Review Verdict Round 1: NOT_APPROVED] |
The descent script let cd print its diagnostic, which echoes the requested directory. A missing directory whose name contains the start sentinel therefore put the sentinel on stderr ahead of the no-directory marker, and settle_descent reported a command as started that never ran. The script now discards cd's diagnostic, so nothing it writes before its fixed words comes from the caller. settle_descent also stopped looking for the marker once more than 64 KiB preceded it, reporting a known missing directory as a sudo refusal. The marker is now honoured wherever it lands ahead of any sentinel, with its reason still capped at 64 KiB. Part of #133
|
[Author Round 1] I fixed both review items on PR #136 and pushed them as
PR description:
The |
|
[Reviewer Round 2] [Reviewer Round 2] — Request changes. Both Round 1 findings are resolved in the updated diff. Failed New finding: a sudoers denial can be reported as |
|
[Review Verdict Round 2: NOT_APPROVED] |
When sudoers denies a command, sudo repeats the whole command line on stderr. The descent script held the start sentinel and the no-directory marker literally, so a denial echoed them and settle_descent reported a start for a command that never ran. The script now prints each word from two halves, so its text holds neither. The caller's command, arguments, profile entries and working directory reach the same echoed line, so any of them holding either word is refused before building. Arguments had no variant for this, so DescentError gains InvalidArgument, which carries only the argument's position. Part of #133
|
[Author Round 2] I fixed the Round 2 finding and pushed it as A sudoers denial could be reported as
Per the instructions, I posted no PR comment. |
|
[Reviewer Round 3] [Reviewer Round 3] — Approve. No unresolved findings. The Round 2 denial case is fixed. The script contains neither outcome marker as a literal, and validation prevents caller supplied words from putting either marker into a denial that echoes the command line (executor.rs, executor.rs). The new denial test exercises both descent kinds (executor.rs). The Round 1 directory failures remain resolved. The shared announcement helper preserves the channel’s limit rule, and descent settling still recognizes a directory marker beyond the limit (channel.rs, executor.rs). The declared deviations have sound reasons and match the code. The supplied PR body has the required |
|
[Review Verdict Round 3: APPROVED] |
Suggested squash commitTitle Body |
Summary
Adds
InDaemonExecutor::descent_commandandInDaemonExecutor::settle_descent. With them, bootler's root session helper can get astd::process::Commandthat descends throughsudoto a service account or to an authenticated operator, and applies a pinned execution profile aftersudohas chosen the identity. The helper sets stdio and its own process group and spawns the command itself. It does not re-implement descent, the sentinel script or elevation classification.src/executor.rs:Descent(Service(ServiceAccount)/Operator(OperatorIds)),OperatorIds,DescentProfile,DescentErrorandDescentSettle, all with rustdoc and# Errors. None of them hasFromStr,Deserializeor aFromimpl.OperatorIds::newrefuses a uid or gid of0oru32::MAX.sudo_descenthelper builds thesudowords for both paths.run'sIdentity::Servicepath anddescent_commandboth call it, so a service account gets the samesudo -u <account>words asrun. An operator getssudo -u #<uid> -g #<gid>, sosudosets the uid, the primary gid and the group-database groups as a login would, with nopre_execand no newunsafe. Neither path passes-nor-Sor sends a password line.sh -c <script> bootler-descent <cwd> <K=V>… <command> <args…>. The script enterscwd, or prints a fixed no-directory marker and exits 1. It discardscd's own diagnostic, which would echo the path, so nothing the script writes before its fixed words comes from the caller. Otherwise it printsSUDO_OK_SENTINELandexecsenv -i "$@". Every value is its own argv word and none is spliced into the script text. The script's text contains neither the sentinel nor the marker: it prints each from two halves (printf '%s%s' '__BOOTLER' '_SUDO_OK__'). When sudoers denies a command,sudorepeats the whole command line on stderr, script included, and a script holding the literal sentinel made that denial settle asStarted. The returnedCommandsets only its program, its arguments and the working directory/. It sets no stdio, process group, session, uid/gid,pre_execor environment, anddescent_commandnever spawns anything.commandmust be an absolute path with no=. Env names must match[A-Za-z_][A-Za-z0-9_]*and appear once, and no value may contain NUL.cwdmust be absolute with no NUL. No word the caller supplies (command, argument, env name or value,cwd) may contain the sentinel or the no-directory marker, because a sudoers denial would echo it. An argument holding one is refused asDescentError::InvalidArgument { index }, and the others through their existing variants. No error variant or message carries an env value.judgeinsrc/executor/channel.rsinto one private helper,channel::announcement, which bothopen_channel'sjudgeandsettle_descentcall.open_channel's behaviour and its tests are unchanged.settle_descentadds the no-directory marker on top of the helper. A marker ahead of any sentinel givesNoWorkingDirectoryhowever much stderr came before it, with the reason capped at 64 KiB. For a refusal it returns the sameExecutorError::SudoRefusedthatclassify_elevationproduces for that stderr. It also refuses runaway output while the stream is still open.K=Vwords appear in the process table untilexec, so no secret belongs in the profile. It says that umask and resource limits come from the caller,sudoand PAM, not from this crate. It also sayssudoand the command stay in the caller's process group only when there is no controlling terminal.Identity::Operatorinside the daemon still returnsNoOperatorIdentity. No existing public signature orExecutorErrorvariant changes. There is no new dependency and no newunsafe. There is noCHANGELOG.mdentry because deploy-core has no release.Tests use
sudostubs undertempfile::tempdir()and cover:/proc/<pid>/staton Linux, with one stub that stays alive and one thatexecs;An
#[ignore]test runs the realsudoas root to a real account and checks uid, gid, groups, environment, directory and process group. CI does not run it.Closes #133
Deviations from the issue
NoWorkingDirectory'sreasonis capped and trimmed, and never carriescd's own diagnostic. The issue gives the reason as "the text before" the marker. The implementation takes at most the first 64 KiB of that text and trims surrounding whitespace, as theRefusedreasons are capped and trimmed. That keeps every reasonsettle_descentreturns bounded and formatted the same way. The descent script also sendscd's diagnostic to/dev/null(cd -- "$1" 2>/dev/null || …), so the text before the marker is only whatsudoand its PAM session wrote, usually nothing. The shell's diagnostic echoes the requested path, so a missing directory whose name containsSUDO_OK_SENTINELwould otherwise have been reported asStarted. Such a path is now also refused before building (see the next entry). The diagnostic stays discarded so that nothing the script writes before its fixed words comes from the caller. The caller already knows which directory it asked for. What it loses is the shell's errno text, such as "No such file or directory" as opposed to "Permission denied".DescentErrorgains anInvalidArgument { index: usize }variant, and every caller-supplied word is refused if it holds a word the descent reports its outcome with. The issue fixesDescentErrorat four variants and does not validate arguments, and it requires every argument to arrive intact. When sudoers denies a command,sudoprints the whole command line on stderr, including the command, its arguments, theK=Vwords andcwd. If any of them containedSUDO_OK_SENTINELor the no-directory marker, that denial would settle asStartedorNoWorkingDirectoryrather than theRefusedthe issue requires. So the command, each argument, each env name and value, andcwdare refused if they contain either word. Arguments had no variant to report this, soInvalidArgumentcarries only the argument's position. The other inputs use their existing variants, with the docs and messages extended. No other argument is refused, and every argument that passes still arrives intact.DescentProfilealso derivesCloneandCopy. The issue's sketch shows no derives on it. The struct holds only two borrowed references, so copying it costs nothing. It gains noDebug,FromStr,DeserializeorFromimpl.src/executor.rsnow readsDuration::from_mins(5)instead ofDuration::from_secs(300), which is the same five minutes. Clippy on the current stable toolchain flags the old form under-D warnings, so CI's clippy run would fail without the change. The test behaves exactly as before.Test plan
cargo fmt -- --check --config group_imports=StdExternalCratepassescargo clippy --all-targets -- -D warningspassescargo clippy --all-targets --features test-support -- -D warningspassesRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --features test-supportpassescargo testpassescargo test --features test-supportpassesCI's
Platform (ubuntu-24.04-arm)job (cargo test --features test-support) andPlatform (macos-latest)job (cargo build --all-targets --features test-support) passArgv:
a_service_descends_as_run_does_and_an_operator_by_id. With a recording stub,Service(ServiceAccount::Insight)shows the samesudo -u <account>prefix thatrunshows.Operator(OperatorIds::new(1000, 1000))shows-u #1000 -g #1000. Neither shows-n,-Sor-p, and every script, directory,K=V, command and argument word follows as a word of its ownBuilt, not spawned:
the_command_is_built_and_not_spawned. The returnedCommandhas thesudoprogram, working directory/and no environment change, and building it runs nothingProfile:
the_command_sees_exactly_the_profile_in_its_directory. The test goes through a descending stub that also skips-g <group>, and the parent environment carries an extra variable set viaCommand::envon the returnedCommand./usr/bin/envstill prints exactly the six profile pairs, and/bin/pwdprintscwdArguments:
every_argument_and_value_arrives_intact./usr/bin/printf '%s\n'prints back, unchanged, arguments with spaces, quotes,$(…), backticks, a newline, a leading-,=after the command, an empty word and*. A literal$AWKWARDargument is not expanded, and a profile value with spaces, quotes,$(x)and a newline arrives unchangedProcess group (Linux):
the_descent_stays_in_the_group_it_is_spawned_into. TheCommandis spawned withprocess_group(0)and stdin piped, over a stub that stays alive beside its child and over one thatexecs. While/bin/catblocks on stdin,/proc/<pid>/statof the stub and ofcatboth show the stub's own group. Closing stdin makes both exitSettle, no directory:
a_missing_directory_starts_nothing_and_settles_as_such. A missingcwdgivesNoWorkingDirectory, and the command never runs. Stderr is exactly the marker, nothing echoes the path, and the reason is empty. (A path containingSUDO_OK_SENTINELis now refused before building; see the outcome-word validation test.)Settle, denial echoing the command line:
a_denial_repeating_the_command_line_settles_as_refused. A stub denies as sudoers does, repeating the command line it was asked to run, so its stderr contains the whole script and the arguments. For both descents this givesRefused(SudoRefused)with that text as reason, notStarted. The test also checks that the script text contains neither word. With the old script it fails. A realsudo1.9.16p2 withroot ALL=(ALL:ALL) /usr/bin/truein its sudoers was checked by hand: denying the old script echoed both words, and denying the new one echoed neitherSettle, refused:
a_refusal_settles_as_run_classifies_it. A stub printssudo: unknown userand exits. While the stream is still open this givesPending, and once it has ended it givesRefused(SudoRefused), whose host and reason equal whatclassify_elevationreturns for the same stderrSettle, sentinel and limit:
settling_follows_the_sentinel_and_the_transport_limitcovers these cases:Pending, thenStartedwith the offset just past itStartedRefusedwhile notendedPendingRefusedwith the first 64 KiB as reason, and so does a marker after that sentinelNoWorkingDirectory, ended or not, with the first 64 KiB as reasonValidation:
an_invalid_descent_is_refused_before_building. These are all refused before building:=-bearing or empty command=or are non-ASCIIcwd0oru32::MAXNeither the
Displaynor theDebugtext of any env error contains the valueValidation, outcome words:
a_word_holding_an_outcome_word_is_refused_before_building. For both the sentinel and the no-directory marker, each of these is refused before building: a command containing the word (InvalidCommand), an argument containing it (InvalidArgument { index: 1 }), an env name equal to it or a value containing it (InvalidEnvironment, and the value does not leak), and acwdcontaining it (InvalidWorkingDirectory)Shared settling: Add a long-lived elevated channel to Executor #132's channel tests in
src/executor/channel.rspass unchanged now thatjudgegoes through the sharedannouncementhelperRegression:
operator_inside_the_daemon_refuses_and_runs_nothingand the other existing in-daemon tests pass unchangedReal
sudo(manual, not run by CI):descent_through_the_real_sudodescends to thenobodyaccount twice, once as a service account and once by ids as an operator. Each time it checks uid, gid, groups, environment, directory, argument passing and process group. It needs root,sudo, and no controlling terminal, hencesetsid -w.Run record. The test ran as root inside a container with no controlling terminal:
setsid -w cargo test --features test-support -- --ignored --nocapture descent_through_the_real_sudo. Environment:rust:latestimage on an arm64 host: Debian GNU/Linux 13 (trixie), kernel7.0.14aarch64, rustc 1.98.0sudoinstalled from Debian with its default sudoers;sudo -VreportsSudo version 1.9.16p2nobodyis uid 65534, gid 65534 (nogroup), groups 65534Output:
In that run
nobodyhad only its primary group. So the groups check confirmed the ids but could not tellsudo's group-database groups apart from a descent that drops every supplementary group.Second run, with a supplementary group. A later run used the same image, kernel, rustc and
sudo 1.9.16p2. This timenobodywas first added to a supplementary group withusermod -aG users nobody, soid nobodygaveuid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup),100(users). The same command then showed both descents receiving the group-database group:Third run, after the script began printing its words from two halves. The same image, kernel (
7.0.14-orbstackaarch64), rustc 1.98.0,sudo 1.9.16p2and supplementaryusersgroup gave:The full
cargo test --features test-supportalso ran on Linux in the same container, and every descent test passed, includingthe_descent_stays_in_the_group_it_is_spawned_into. One test failed:retain::tests::writable_ancestors_are_refused, which this PR does not touch. It fails only because the container runs as root, where the ancestor-permission check does not apply. The same test binary passes when run asnobody, and the test passes in CI's non-root Linux job.