feat/sudo-priv-mode #129

Merged
clawdie merged 3 commits from feat/sudo-priv-mode into main 2026-06-21 16:06:52 +02:00
3 changed files with 92 additions and 48 deletions

View file

@ -94,12 +94,14 @@ pub enum PrivMode {
Mdo,
/// Setuid helper that performs only the jail spawn — deployed hosts.
Helper,
/// `sudo -n <cmd>` — hosts with sudo configured (no password prompt).
Sudo,
/// No escalation: run the jail command directly.
None,
}
impl PrivMode {
/// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `none`).
/// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `sudo` | `none`).
/// Defaults to `Mdo` (the live-USB posture); only consulted when a spawn
/// actually requests a jail.
pub fn from_env() -> Self {
@ -110,6 +112,7 @@ impl PrivMode {
.as_str()
{
"helper" => PrivMode::Helper,
"sudo" => PrivMode::Sudo,
"none" => PrivMode::None,
_ => PrivMode::Mdo,
}
@ -169,6 +172,11 @@ fn priv_wrap(
a.extend(argv);
("mdo".to_string(), a)
}
PrivMode::Sudo => {
let mut a = vec!["-n".to_string(), exe];
a.extend(argv);
("sudo".to_string(), a)
}
PrivMode::Helper => {
let mut a = vec![exe];
a.extend(argv);
@ -247,6 +255,54 @@ async fn remove_staged_dir(path: &Path) {
let _ = tokio::fs::remove_dir_all(path).await;
}
async fn spawn_prepared_child(
prepared: &PreparedSpawnCommand,
agent_config: &AgentSpawnConfig,
) -> Result<(Child, Option<ChildStdout>), SpawnerError> {
let mut cmd = Command::new(&prepared.program);
cmd.args(&prepared.argv)
.envs(&prepared.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if agent_config.jail.is_none() {
if let Some(ref dir) = agent_config.working_dir {
cmd.current_dir(dir);
}
}
let mut child = cmd.spawn().map_err(SpawnerError::Io)?;
// Brief grace period for the process to start.
tokio::time::sleep(Duration::from_millis(500)).await;
// Check it has not immediately crashed.
match child.try_wait() {
Ok(Some(status)) => {
let stderr = if let Some(ref mut stderr) = child.stderr.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
String::from_utf8_lossy(&buf).to_string()
} else {
String::new()
};
if let Some(path) = prepared.cleanup_dir.as_deref() {
remove_staged_dir(path).await;
}
Err(SpawnerError::Io(std::io::Error::other(format!(
"process exited immediately with status {status}: {stderr}"
))))
}
Ok(None) => {
let stdout = child.stdout.take();
Ok((child, stdout))
}
Err(e) => Err(SpawnerError::Io(e)),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn prepare_spawn_command(
binary: &str,
@ -679,50 +735,7 @@ impl Spawner {
);
let _startup_timeout = agent_config.startup_timeout_secs;
let op = || async {
let mut cmd = Command::new(&prepared.program);
cmd.args(&prepared.argv)
.envs(&prepared.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if agent_config.jail.is_none() {
if let Some(ref dir) = agent_config.working_dir {
cmd.current_dir(dir);
}
}
let mut child = cmd.spawn().map_err(SpawnerError::Io)?;
// Brief grace period for the process to start
tokio::time::sleep(Duration::from_millis(500)).await;
// Check it hasn't immediately crashed
match child.try_wait() {
Ok(Some(status)) => {
let stderr = if let Some(ref mut stderr) = child.stderr.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
String::from_utf8_lossy(&buf).to_string()
} else {
String::new()
};
if let Some(path) = prepared.cleanup_dir.as_deref() {
remove_staged_dir(path).await;
}
Err(SpawnerError::Io(std::io::Error::other(format!(
"process exited immediately with status {status}: {stderr}"
))))
}
Ok(None) => {
let stdout = child.stdout.take();
Ok((child, stdout))
}
Err(e) => Err(SpawnerError::Io(e)),
}
};
let op = || spawn_prepared_child(&prepared, &agent_config);
let backoff = ExponentialBuilder::default()
.with_min_delay(Duration::from_millis(500))
@ -820,6 +833,28 @@ mod jail_tests {
assert_eq!(a, argv(&["-u", "root", "jexec", "pi0", "pi", "-p", "task"]));
}
#[test]
fn named_jail_via_sudo() {
let j = JailConfig {
name: Some("proof0".into()),
root_path: Some("/usr/local/bastille/jails/proof0/root".into()),
user: Some("clawdie".into()),
..Default::default()
};
let (exe, a) = jail_wrap(
"pi",
&argv(&["-p", "task"]),
Some(&j),
&PrivMode::Sudo,
DEFAULT_JAIL_HELPER,
);
assert_eq!(exe, "sudo");
assert_eq!(
a,
argv(&["-n", "jexec", "-U", "clawdie", "proof0", "pi", "-p", "task"])
);
}
#[test]
fn named_jail_with_user_no_priv() {
let j = JailConfig {

View file

@ -106,9 +106,14 @@ Not done: no destructive `apply --yes`; still requires scratch pool/VM.
- [x] `cargo test -p clawdie` passes on FreeBSD 15 (output + versions reported).
- [x] `discover` + `plan` correct against a real FreeBSD ZFS host for read-only/dry-run paths.
- [ ] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as specified; teardown verified.
- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard refuses non-empty disks.
- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR and reported back.
- [x] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as
specified; teardown verified. (OSA file-backed testpool, 2026-06-21.)
- [x] Idempotent re-run: second `apply --yes` skips user creation (exit 65 = already
exists), rc.d + sysrc overwrite cleanly.
- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard
refuses non-empty disks.
- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR
and reported back.
## Platform notes

View file

@ -54,6 +54,10 @@ just `jexec`. We choose the escalation per host via `PrivMode`
root is a real escalation surface, so use a narrow setuid helper
(`/usr/local/libexec/colibri-jail-spawn`) that only performs the jail spawn, and
keep the daemon unprivileged.
- **Validated hosts with existing sudo policy → `sudo`.** `sudo -n` can be used
as an interim proof/ops mode when a narrow sudoers rule already permits the
daemon user to run the jail command without prompting. Prefer the setuid helper
for long-lived production hosts once packaged.
- **`none`** — run the jail command directly (already root, or tests).
## Staged env payloads