From 9538c0ee84908afefa4f1709748bdec49b7ce912 Mon Sep 17 00:00:00 2001 From: Aco Piper Date: Mon, 28 Sep 2026 10:20:16 +0900 Subject: [PATCH 1/5] Add profile-applying descent commands to the daemon 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 --- src/executor.rs | 1139 ++++++++++++++++++++++++++++++++++++++- src/executor/channel.rs | 45 +- 2 files changed, 1168 insertions(+), 16 deletions(-) diff --git a/src/executor.rs b/src/executor.rs index f938cd7..7856ab7 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -516,6 +516,13 @@ const RC_MARKER: &str = "__BOOTLER_RC__:"; /// command that merely exited non-zero — even one whose own stderr mentions a /// password — rather than classifying by scanning combined stderr afterwards. const SUDO_OK_SENTINEL: &str = "__BOOTLER_SUDO_OK__"; +/// Marker the descent script prints on stderr, in place of +/// [`SUDO_OK_SENTINEL`], when the target identity cannot enter the profile's +/// working directory: `sudo` descended, but the command was never started. +const NO_WORKING_DIRECTORY_MARKER: &str = "__BOOTLER_NO_WORKING_DIRECTORY__"; +/// The `$0` the descent script runs under, so a shell diagnostic — `cd`'s, +/// above all — names what printed it. +const DESCENT_ARG0: &str = "bootler-descent"; /// Errors raised by an executor primitive. #[derive(Debug, thiserror::Error)] @@ -2285,6 +2292,23 @@ fn sudo_sentinel_script() -> String { format!("printf '%s' '{SUDO_OK_SENTINEL}' >&2; exec \"$0\" \"$@\"") } +/// The `sh -c` script [`InDaemonExecutor::descent_command`] descends through, +/// run as the target identity. +/// +/// Invoked as `sh -c SCRIPT … `: it enters +/// `` or prints [`NO_WORKING_DIRECTORY_MARKER`] and exits `1`, then +/// announces the start with [`SUDO_OK_SENTINEL`] and replaces itself with +/// `env -i`, which takes the `K=V` words as the whole environment and the first +/// word without `=` as the utility. Every value is a positional word, never +/// spliced into the script text. +fn descent_script() -> String { + format!( + "cd -- \"$1\" || {{ printf '%s' '{NO_WORKING_DIRECTORY_MARKER}' >&2; exit 1; }}; \ + shift; printf '%s' '{SUDO_OK_SENTINEL}' >&2; exec {env} -i \"$@\"", + env = bounded::ENV, + ) +} + /// Removes the first [`SUDO_OK_SENTINEL`] from `stderr`, reporting whether it was /// present (i.e. whether `sudo` elevated and started the wrapped command). fn take_sudo_sentinel(stderr: &mut Vec) -> bool { @@ -3077,6 +3101,12 @@ impl Executor for SshExecutor { /// /// It does not implement [`Executor::open_channel`]: that call returns the /// trait default's [`ChannelError::Unsupported`] here. +/// +/// A root caller that spawns, bounds and kills its own children — and knows +/// an operator's ids from a session it authenticated itself — builds its +/// descents with [`InDaemonExecutor::descent_command`] instead, which applies +/// an execution profile after `sudo` has selected the identity. That is not an +/// [`Identity`], and does not change what [`Identity::Operator`] does here. #[derive(Debug, Clone)] pub struct InDaemonExecutor { host: String, @@ -3138,19 +3168,37 @@ impl InDaemonExecutor { Ok((cmd, false)) } Identity::Service(account) => { - let mut cmd = Command::new(&self.sudo_bin); - cmd.arg("-u") - .arg(account.as_str()) - .arg(shell) - .arg("-c") - .arg(script) - .arg(command) - .args(args); + let mut cmd = self.sudo_descent(Descent::Service(account)); + cmd.arg(shell).arg("-c").arg(script).arg(command).args(args); Ok((cmd, true)) } } } + /// Returns `sudo` with the words that select `who` and nothing after them: + /// the one site building a descent from root, for [`Executor::run`] and its + /// siblings on [`Identity::Service`] and for + /// [`InDaemonExecutor::descent_command`] alike. + /// + /// A service account is `-u `, one word whatever it contains. An + /// operator is `-u # -g #`, the numeric forms `sudo` reads as ids + /// rather than names. + fn sudo_descent(&self, who: Descent) -> Command { + let mut cmd = Command::new(&self.sudo_bin); + match who { + Descent::Service(account) => { + cmd.arg("-u").arg(account.as_str()); + } + Descent::Operator(ids) => { + cmd.arg("-u") + .arg(format!("#{}", ids.uid)) + .arg("-g") + .arg(format!("#{}", ids.gid)); + } + } + cmd + } + /// Spawns a resolved invocation, feeding `payload` verbatim — there is no /// password line to prepend — and settling the sudo sentinel on descent. fn spawn_resolved( @@ -3213,6 +3261,321 @@ impl InDaemonExecutor { } } +/// Who a root process descends to through +/// [`InDaemonExecutor::descent_command`]. +/// +/// Not an [`Identity`]: [`Identity::Root`] has no place here, because a root +/// caller spawns its own root children, and [`Descent::Operator`] names an +/// operator by the ids a session already authenticated, where +/// [`Identity::Operator`] inside the daemon has none to name and refuses. +/// +/// Like [`Identity`], it has no `FromStr`, `Deserialize` or `From` impl: +/// a service account is still one of the closed [`ServiceAccount`] set, so a +/// configured account name cannot become a descent (RFC 0003 §9.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Descent { + /// A bootler-managed service account, reached by `sudo -u ` + /// exactly as [`Identity::Service`] is inside the daemon. + Service(ServiceAccount), + /// An already-authenticated operator, reached by + /// `sudo -u # -g #` — never root. + Operator(OperatorIds), +} + +/// An already-authenticated operator's numeric user and group ids. +/// +/// Built only by [`OperatorIds::new`], which refuses the ids that would not +/// descend: `0`, root's, and `u32::MAX`, `(uid_t)-1`, which the kernel reads +/// as "leave unchanged". There is no `From<(u32, u32)>`, so every pair goes +/// through that check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OperatorIds { + uid: u32, + gid: u32, +} + +impl OperatorIds { + /// Creates the ids of an operator whose session authenticated `uid` and + /// `gid`. + /// + /// # Errors + /// + /// Returns [`DescentError::InvalidOperator`] when either id is `0` or + /// `u32::MAX`. + pub fn new(uid: u32, gid: u32) -> Result { + let descendable = |id: u32| id != 0 && id != u32::MAX; + if descendable(uid) && descendable(gid) { + Ok(Self { uid, gid }) + } else { + Err(DescentError::InvalidOperator { uid, gid }) + } + } + + /// Returns the operator's user id. + #[must_use] + pub fn uid(self) -> u32 { + self.uid + } + + /// Returns the operator's primary group id. + #[must_use] + pub fn gid(self) -> u32 { + self.gid + } +} + +/// The execution profile [`InDaemonExecutor::descent_command`] applies to the +/// command after `sudo` has selected the identity. +/// +/// **No secret belongs in `env`.** Each pair travels as a `K=V` argument word +/// of `sudo` and of the shell it starts, so it is readable in the process +/// table by any local user until the command replaces them. +#[derive(Clone, Copy)] +pub struct DescentProfile<'a> { + /// The command's whole environment, as `(name, value)` pairs. Each name + /// matches `[A-Za-z_][A-Za-z0-9_]*` and appears once; no value contains + /// a NUL byte. + pub env: &'a [(&'a str, &'a str)], + /// The command's working directory: absolute, with no NUL byte. + pub cwd: &'a Path, +} + +/// Errors raised building a descent, before anything is built. +/// +/// No variant carries an environment value, so none can reach a log through +/// this error. +#[derive(Debug, thiserror::Error)] +pub enum DescentError { + /// `command` is not an absolute path, or contains `=`. + /// + /// The command runs with only the profile's environment, and is started + /// through `env -i`, which reads an operand containing `=` as an + /// assignment rather than the utility. + #[error("command `{command}` is not an absolute path free of `=`")] + InvalidCommand { + /// The command as the caller named it. + command: String, + }, + /// An entry of [`DescentProfile::env`] was refused: its name is not + /// `[A-Za-z_][A-Za-z0-9_]*`, it is given twice, or its value contains a + /// NUL byte. The value is never carried. + #[error("environment variable `{name}` {reason}")] + InvalidEnvironment { + /// The variable's name. + name: String, + /// Why it was refused. + reason: &'static str, + }, + /// [`DescentProfile::cwd`] is not absolute, or contains a NUL byte. + #[error("working directory `{}` is not an absolute path free of NUL", cwd.display())] + InvalidWorkingDirectory { + /// The directory as the caller named it. + cwd: PathBuf, + }, + /// An operator's uid or gid is `0` or `u32::MAX`. + #[error("operator uid {uid} and gid {gid} cannot be descended to: neither may be 0 or {max}", max = u32::MAX)] + InvalidOperator { + /// The uid given. + uid: u32, + /// The gid given. + gid: u32, + }, +} + +/// What [`InDaemonExecutor::settle_descent`] reads in the standard error a +/// descent has written so far. +#[derive(Debug)] +pub enum DescentSettle { + /// Undecided: read more, and ask again. + Pending, + /// `sudo` descended and the command has been started in the profile's + /// directory. Standard error from `command_stderr_from` on is the + /// command's; everything before it is not. + Started { + /// The offset just past the start announcement. + command_stderr_from: usize, + }, + /// `sudo` descended, but the target identity could not enter the + /// profile's working directory, so the command was never started. + NoWorkingDirectory { + /// What the shell wrote on failing to enter it, trimmed. + reason: String, + }, + /// `sudo` refused before the command could start — or wrote more than + /// 64 KiB before announcing a start, which is treated the same way — with + /// the error [`Executor::run`] reports for that refusal. The caller kills + /// the child. + Refused(ExecutorError), +} + +/// Refuses a descent [`InDaemonExecutor::descent_command`] cannot build as +/// asked. +fn check_descent(command: &str, profile: &DescentProfile<'_>) -> Result<(), DescentError> { + if !is_absolute_and_plain(command) { + return Err(DescentError::InvalidCommand { + command: command.to_string(), + }); + } + let mut seen = std::collections::HashSet::new(); + for &(name, value) in profile.env { + let refuse = |reason| DescentError::InvalidEnvironment { + name: name.to_string(), + reason, + }; + if !is_env_name(name) { + return Err(refuse("is not a name of the form [A-Za-z_][A-Za-z0-9_]*")); + } + if !seen.insert(name) { + return Err(refuse("is given more than once")); + } + if value.contains('\0') { + return Err(refuse("has a value containing a NUL byte")); + } + } + let cwd = profile.cwd; + if !cwd.is_absolute() || cwd.as_os_str().as_encoded_bytes().contains(&0) { + return Err(DescentError::InvalidWorkingDirectory { + cwd: cwd.to_path_buf(), + }); + } + Ok(()) +} + +/// Reports whether `name` matches `[A-Za-z_][A-Za-z0-9_]*`, the portable form +/// of an environment variable name. +fn is_env_name(name: &str) -> bool { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +impl InDaemonExecutor { + /// Builds, without spawning, the command that descends from this root + /// process to `who` and runs `command` with `args` under `profile`. + /// + /// The command is + /// `sudo /bin/sh -c