feat/sudo-priv-mode #129
3 changed files with 92 additions and 48 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue