Skip to content

Build profile-applying descent commands in the root daemon #133

Description

@sehkone

Why

bootler's session helper is a root process inside a systemd transient service. It applies a pinned execution profile to every command it spawns: exactly PATH, HOME, USER, LOGNAME, LANG and LC_ALL, and the admitted working directory (/ for run), with no TTY (aicers/bootler#416 Context and Scope; aicers/bootler#418 Scope item 3). Root children are bootler's own. Two identities need this crate:

  • Service(account). The profile must be applied after sudo -u identity selection, because sudo's env_reset discards an outer environment (aicers/bootler#416 "Identity selection comes first").
  • Operator. The command must run as the uid and gid the session handshake authenticated, never as root (aicers/bootler#418 Scope item 3).

bootler must not re-implement sudo -u descent, the sentinel script, elevation classification or any uid/gid switch (aicers/bootler#416, #418 Constraints). Its helper obtains a std::process::Command, sets stdio and process_group(P) and spawns it itself, so the identity-selecting process and its descendants stay in its anchored group P; a hook that cannot yield such a Command blocks aicers/bootler#418 (Scope item 3).

InDaemonExecutor can do neither: its resolution is private, it applies no environment or directory, and it refuses Identity::Operator with NoOperatorIdentity. run_with_input spawns internally, in its own process group, with an always-empty environment.

Scope

Add to src/executor.rs (all public items with rustdoc and # Errors; these names are decided, private helpers are the implementer's):

/// Who a root process descends to. Not an `Identity`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Descent {
    Service(ServiceAccount),
    Operator(OperatorIds),
}

/// An already-authenticated operator's numeric ids. Fields private.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OperatorIds { /* uid: u32, gid: u32 */ }
impl OperatorIds {
    pub fn new(uid: u32, gid: u32) -> Result<Self, DescentError>;
    pub fn uid(self) -> u32;
    pub fn gid(self) -> u32;
}

pub struct DescentProfile<'a> {
    pub env: &'a [(&'a str, &'a str)],
    pub cwd: &'a Path,
}

#[derive(Debug, thiserror::Error)]
pub enum DescentError {
    InvalidCommand { command: String },
    InvalidEnvironment { name: String, reason: &'static str }, // never carries a value
    InvalidWorkingDirectory { cwd: PathBuf },
    InvalidOperator { uid: u32, gid: u32 },
}

#[derive(Debug)]
pub enum DescentSettle {
    Pending,
    Started { command_stderr_from: usize },
    NoWorkingDirectory { reason: String },
    Refused(ExecutorError),
}

impl InDaemonExecutor {
    pub fn descent_command(&self, who: Descent, command: &str, args: &[&str],
                           profile: &DescentProfile<'_>) -> Result<Command, DescentError>;
    pub fn settle_descent(&self, stderr: &[u8], ended: bool) -> DescentSettle;
}

Validation, before anything is built. command is an absolute path free of =. Each env name matches [A-Za-z_][A-Za-z0-9_]*, appears once, and its value contains no NUL. cwd is absolute and contains no NUL. OperatorIds::new refuses a uid or gid of 0 or u32::MAX ((uid_t)-1 means "unchanged" to the kernel). Errors never include an env value.

The command built. Both variants descend through sudo, from the same site that builds run's descent (resolve_through or a helper both call), so the sudo binary and the -u <account> words for a service account equal run's:

  • Service(account): sudo -u <account> /bin/sh -c <script> <arg0> <cwd> <K=V>… <command> <args…>
  • Operator(ids): sudo -u #<uid> -g #<gid> /bin/sh -c <script> <arg0> <cwd> <K=V>… <command> <args…>

No -n, no -S, no password line: descent from root never prompts, as for run in the daemon. The fixed <script>, run as the target identity:

  1. cd -- "$1"; on failure, print a fixed no-directory marker on stderr and exit non-zero;
  2. shift, print SUDO_OK_SENTINEL on stderr;
  3. exec /usr/bin/env -i "$@".

env -i takes the K=V words as the whole environment and the first word without =, the command, as the utility. So the command sees exactly profile.env, runs in profile.cwd, and keeps every argument as one word. Every value is one discrete argv word, never spliced into the script. The rustdoc warns that the K=V words are visible in the process table until exec, so no secret belongs in profile.env.

The returned Command has its program, arguments and working directory / set (so sudo never depends on the caller's directory), and nothing else: no stdio, no process_group, no setsid, no uid/gid, no pre_exec, no environment change. The caller sets stdio and its process group and spawns it. descent_command never spawns.

Why sudo for the operator. CommandExt::uid run as root drops every supplementary group (std calls setgroups(0, …)), and setting the operator's groups needs initgroups in a pre_exec, which is new unsafe. sudo -u #uid -g #gid sets the operator's uid, primary gid and group-database groups as a login would, goes through the same sentinel classification as a service account, and keeps one descent path. A host whose sudoers does not let root run as that uid and gid gives Refused; nothing falls back.

Classification. settle_descent classifies what the spawned child has written to stderr so far (ended is true once stderr reached EOF or the child exited):

  • sentinel present at an offset within TRANSPORT_STDERR_LIMIT (64 KiB): Started, where command_stderr_from is the offset just past it; later bytes are the command's;
  • sentinel present only beyond that limit: Refused(SudoRefused) with the bytes before it, capped at 64 KiB, as reason — the same rule Add a long-lived elevated channel to Executor #132's channel applies wherever the sentinel lands;
  • no-directory marker present: NoWorkingDirectory with the text before it;
  • neither, and ended: Refused(ExecutorError::SudoRefused { host, reason }), reason being the trimmed stderr, exactly as classify_elevation does with no SudoAuth;
  • neither, and more than 64 KiB not counting a trailing fragment that may still grow into the sentinel: Refused(SudoRefused) with the first 64 KiB as reason, even when not ended; the caller then kills the child;
  • otherwise Pending.

It never returns Started for bytes that merely contain the sentinel's prefix. The sentinel search and the limit rule are the ones judge in src/executor/channel.rs already implements for open_channel (#132): factor them into one private helper that both call, rather than a second copy; the no-directory marker is this issue's addition on top.

Environment the command does not get from this crate. Umask and resource limits are not set here: they are what the parent passes and sudo and its PAM session leave (sudoers umask is unioned with the parent's). The rustdoc says so. sudo closes descriptors above 2, so only stdio reaches the command. With no controlling terminal, as in a systemd service, sudo allocates no pty and runs the command without a new session; the rustdoc names this as the condition under which the command stays in the caller's process group.

Identity model. Descent and OperatorIds get no FromStr, Deserialize, From<String> or From<(u32, u32)>. Descent::Service takes the existing closed ServiceAccount, so a configured account name still cannot become an identity (RFC 0003 §9.2). Identity gains nothing and Identity::Operator in run, run_in, run_with_input and fetch_file inside the daemon still returns NoOperatorIdentity.

Unchanged. put_file, fetch_file, hard_link_over and their durability; run, run_with_input, open_channel (still the trait default Unsupported for InDaemonExecutor) and the other Executor methods; open_channel's start script and settling behaviour, which the shared helper must preserve; Identity, ServiceAccount, ExecutorError's variants; LocalExecutor and SshExecutor.

Relation to run_with_input. That stays the bounded, self-spawning primitive. descent_command only builds, for a root caller that spawns, bounds and kills itself; being root, the caller can signal the whole group, so no supervisor is needed.

Constraints. No new dependency, no new unsafe, no pre_exec, no as casts on ids, no bare unwrap(), no input-derived expect(). No CHANGELOG.md entry: deploy-core has no release.

Acceptance criteria

  • The items above exist as specified, with rustdoc and # Errors.
  • For Service, the words before /bin/sh equal run's InDaemonExecutor descent for that account; for Operator they are sudo -u #<uid> -g #<gid>.
  • The spawned command sees exactly profile.env, runs in profile.cwd, and receives every argument intact, including spaces, quotes, $(…), newlines, a leading -, and = after the command.
  • The returned Command sets no stdio, process group, session, uid, gid, environment or pre_exec; its working directory is /; descent_command spawns nothing.
  • A child spawned from it with process_group(P) reports P in /proc/<pid>/stat (Linux) for the sudo stub and for the command.
  • settle_descent returns each of its six outcomes as specified, and Refused matches classify_elevation's SudoRefused for the same stderr. Its sentinel and limit decisions come from the helper open_channel also uses, and Add a long-lived elevated channel to Executor #132's channel tests pass unchanged.
  • Invalid command, env name, duplicate name, NUL value, relative cwd, and uid or gid 0 or u32::MAX are refused before building, and no error text contains an env value.
  • Identity::Operator inside the daemon still returns NoOperatorIdentity from run and run_with_input; existing tests pass unchanged; no existing public signature or ExecutorError variant changes.
  • An #[ignore] test runs the real sudo as root to a real account and checks uid, gid, groups, environment, directory and process group; the PR description records one run of it (command, environment, sudo -V version, output), or states it was not run and why. CI does not run it.
  • The fmt, both clippy, doc and both test invocations CI runs pass, including the ubuntu-24.04-arm and macos-latest jobs.

Test plan

Unit tests in src/executor.rs's test module, with with_sudo_bin stubs written by write_script under tempfile::tempdir(); no real sudo, no network, no process-environment mutation. A descending stub must also skip -g <group>.

  • Argv. A recording stub prints its argv: Service(ServiceAccount::Insight) shows the same prefix run shows; Operator(OperatorIds::new(1000, 1000)) shows -u #1000 -g #1000; neither shows -n or -S.
  • Profile. Through a descending stub, /usr/bin/env prints exactly the six profile pairs, with a parent environment carrying an extra variable set only via Command::env on the returned Command; /bin/pwd prints cwd.
  • Arguments. /usr/bin/printf '%s\n' with the awkward arguments listed above prints each back unchanged; a profile value with spaces and quotes arrives unchanged.
  • Process group (Linux). Spawn the Command with process_group(0) and stdin piped, over a stub that stays alive beside its child and over one that execs; the command is /bin/cat; while it blocks on stdin, /proc/<pid>/stat of the stub and of cat show the stub's own group; close stdin and both exit.
  • Settle. Sentinel split across two calls gives Pending, then Started with the right offset; a stub printing sudo: unknown user and exiting gives Refused; cwd a missing directory gives NoWorkingDirectory; 64 KiB plus one byte with no sentinel gives Refused while not ended; 64 KiB followed by a partial sentinel gives Pending; a sentinel arriving after more than 64 KiB gives Refused.
  • Validation. One case per refusal in the acceptance criteria.
  • Regression. operator_inside_the_daemon_refuses_and_runs_nothing and the other existing in-daemon tests pass unchanged.

Out of scope

  • Any bootler change or adoption (aicers/bootler#416 and #418 adopt this by a pin bump).
  • Root descent (bootler spawns its own root children), LocalExecutor and SshExecutor.
  • Spawning, bounding, cancelling or reaping the command; setting umask or resource limits; resolving an operator's passwd entry.
  • Changing sudoers, PAM or any host policy.

Dependencies

None open. #132 (Executor::open_channel, merged at 87a7b66) added src/executor/channel.rs in the same area; share its sentinel settling as described in Scope, and keep its tests passing unchanged.

Pointers

At 87a7b66 (deploy-core main after #132):

  • src/executor.rs:3081 InDaemonExecutor, :3109 resolve, :3123 resolve_through (its NoOperatorIdentity arm), :3156 spawn_resolved, :3176 run_bounded; :3216 impl Executor for InDaemonExecutor.
  • src/executor.rs:572 NoOperatorIdentity; :754 ServiceAccount; :982 Identity; :1220 the trait's default open_channel.
  • src/executor.rs:518 SUDO_OK_SENTINEL, :2284 sudo_sentinel_script, :2290 take_sudo_sentinel, :2326 classify_elevation; :501 CD_EXEC_SCRIPT, the positional-argument pattern.
  • src/executor.rs:730 check_bounded_command; src/executor/bounded.rs:65 TRANSPORT_STDERR_LIMIT, :71 ENV, :318 the supervisor's env -i usage.
  • src/executor/channel.rs:488 judge (sentinel search, transport limit wherever the sentinel lands, trailing-fragment exclusion), :407 settle_start, and its tests from :930.
  • Tests: src/executor.rs:3345 write_script, :3784–:3841 the in-daemon resolution tests (operator_inside_the_daemon_refuses_and_runs_nothing at :3811), :6929 descending_sudo.

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions