diff --git a/hh/src/app.rs b/hh/src/app.rs index b1ed4bb..f48c5e4 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -65,7 +65,7 @@ pub enum Net { SbxResize { rows: u16, cols: u16 }, SbxData(Vec), SbxInput { from: String, bytes: Vec }, - Perm { owner: String, drivers: Vec }, + Perm { owner: String, drivers: Vec, sudoers: Vec }, Ft(ft::Ft), Sys(String), Closed, @@ -87,6 +87,8 @@ pub struct App { pub driving: bool, pub owner: Option, pub drivers: std::collections::HashSet, + /// Members whose VM unix account has sudo (superuser). Always includes owner. + pub sudoers: std::collections::HashSet, pub pending_offer: Option, transfers: HashMap, } @@ -104,6 +106,7 @@ impl App { driving: false, owner: None, drivers: std::collections::HashSet::new(), + sudoers: std::collections::HashSet::new(), pending_offer: None, transfers: HashMap::new(), } @@ -158,6 +161,7 @@ impl App { self.driving = false; self.owner = None; self.drivers.clear(); + self.sudoers.clear(); self.sys("⛧ sandbox dismissed"); } } @@ -172,8 +176,9 @@ impl App { } } Net::SbxInput { .. } => {} // broker enforces + writes in the run loop - Net::Perm { owner, drivers } => { + Net::Perm { owner, drivers, sudoers } => { let new: std::collections::HashSet = drivers.into_iter().collect(); + let sudo: std::collections::HashSet = sudoers.into_iter().collect(); if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) { self.sys(format!("⛧ {owner} is the superuser (sandbox owner)")); } @@ -183,8 +188,12 @@ impl App { self.driving = false; self.sys("⛧ your drive permission was revoked"); } + if sudo.contains(&self.me) && !self.sudoers.contains(&self.me) && self.owner.is_some() { + self.sys("⛧ you were granted sudo (superuser) in the VM"); + } self.owner = Some(owner).filter(|o| !o.is_empty()); self.drivers = new; + self.sudoers = sudo; } Net::Ft(_) => {} // handled in the run loop (needs out channel + disk) Net::Sys(t) => self.sys(t), @@ -230,7 +239,10 @@ fn send_frame(out: &UnboundedSender, room: &fernet::Fernet, value: serde_ fn broadcast_acl(out: &UnboundedSender, room: &fernet::Fernet, app: &App) { let drivers: Vec<&String> = app.drivers.iter().collect(); - send_frame(out, room, json!({"_perm":"acl","owner": app.owner, "drivers": drivers})); + let sudoers: Vec<&String> = app.sudoers.iter().collect(); + send_frame(out, room, json!({ + "_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers + })); } /// Stream a payload to the coven as `_ft` chunks (background, paced). @@ -421,6 +433,8 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { app.owner = Some(app.me.clone()); app.drivers.clear(); app.drivers.insert(app.me.clone()); + app.sudoers.clear(); + app.sudoers.insert(app.me.clone()); // owner = superuser send_frame(&out_tx, &session.room, json!({ "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols })); @@ -538,8 +552,10 @@ fn handle_command( let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24)); let (rows, cols) = sbx_dims(sz.0, sz.1); *launching = true; - app.sys(format!("summoning {} sandbox… (multipass boot can take ~30s)", backend.label())); - spawn_launch(backend, image, rows, cols, pty_tx.clone(), broker_tx.clone(), app_tx.clone()); + let members: Vec = app.users.iter().map(|u| u.username.clone()).collect(); + app.sys(format!("summoning {} sandbox… (provisioning unix users; multipass boot ~30s)", backend.label())); + spawn_launch(backend, image, app.me.clone(), members, rows, cols, + pty_tx.clone(), broker_tx.clone(), app_tx.clone()); } } Some("stop") => { @@ -556,6 +572,36 @@ fn handle_command( } _ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"), } + } else if let Some(rest) = line.strip_prefix("/unsudo") { + let target = rest.trim(); + if !app.is_owner() { + app.sys("only the owner can /unsudo"); + } else if target.is_empty() { + app.sys("usage: /unsudo "); + } else if let Some((be, name)) = broker_meta.clone() { + app.sudoers.remove(target); + let (t, n) = (target.to_string(), name); + tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, false)); + broadcast_acl(out_tx, room, app); + app.sys(format!("revoked sudo from {target} in the VM")); + } else { + app.sys("no sandbox running"); + } + } else if let Some(rest) = line.strip_prefix("/sudo") { + let target = rest.trim(); + if !app.is_owner() { + app.sys("only the owner can delegate sudo"); + } else if target.is_empty() { + app.sys("usage: /sudo (delegate VM superuser) | /unsudo "); + } else if let Some((be, name)) = broker_meta.clone() { + app.sudoers.insert(target.to_string()); + let (t, n) = (target.to_string(), name); + tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, true)); + broadcast_acl(out_tx, room, app); + app.sys(format!("delegated VM superuser (sudo) to {target}")); + } else { + app.sys("no sandbox running"); + } } else if let Some(rest) = line.strip_prefix("/grant") { let target = rest.trim(); if !app.is_owner() { @@ -585,9 +631,12 @@ fn handle_command( } } +#[allow(clippy::too_many_arguments)] fn spawn_launch( backend: sbx::Backend, image: String, + owner: String, + members: Vec, rows: u16, cols: u16, pty_tx: UnboundedSender>, @@ -605,8 +654,15 @@ fn spawn_launch( let _ = broker_tx.send(BrokerMsg::Failed); return; } + // Provision real unix accounts (owner = sudoer) → the shell's run-user. + let run_user = { + let (n, o, ms) = (name.clone(), owner.clone(), members.clone()); + tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms)) + .await + .unwrap_or_default() + }; let (std_tx, std_rx) = std::sync::mpsc::channel::>(); - match sbx::Sandbox::launch(backend, &name, &image, rows, cols, std_tx) { + match sbx::Sandbox::launch(backend, &name, &run_user, rows, cols, std_tx) { Ok(sb) => { std::thread::spawn(move || { while let Ok(b) = std_rx.recv() { diff --git a/hh/src/net.rs b/hh/src/net.rs index 821be94..369244f 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -192,15 +192,18 @@ fn parse_perm(text: &str) -> Option { if v["_perm"].as_str()? != "acl" { return None; } - let drivers = v["drivers"] - .as_array() - .into_iter() - .flatten() - .filter_map(|d| d.as_str().map(str::to_string)) - .collect(); + let list = |key: &str| { + v[key] + .as_array() + .into_iter() + .flatten() + .filter_map(|d| d.as_str().map(str::to_string)) + .collect::>() + }; Some(Net::Perm { owner: v["owner"].as_str().unwrap_or("").to_string(), - drivers, + drivers: list("drivers"), + sudoers: list("sudoers"), }) } diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs index cf394c0..17673fd 100644 --- a/hh/src/sbx.rs +++ b/hh/src/sbx.rs @@ -51,40 +51,55 @@ impl Backend { /// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance /// that already exists. pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> { - if backend != Backend::Multipass { - return Ok(()); + match backend { + Backend::Local => Ok(()), + Backend::Multipass => { + let exists = Command::new("multipass") + .args(["info", name]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !exists { + let st = Command::new("multipass") + .args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image]) + .status() + .context("multipass launch (is multipass installed?)")?; + anyhow::ensure!(st.success(), "multipass launch failed"); + } else { + let _ = Command::new("multipass").args(["start", name]).status(); + } + Ok(()) + } + Backend::Docker => { + // Persistent container so we can exec in to provision users + shells. + let _ = Command::new("docker").args(["rm", "-f", name]).status(); + let st = Command::new("docker") + .args(["run", "-d", "--name", name, "--hostname", name, "-w", "/root", image, "sleep", "infinity"]) + .status() + .context("docker run (is docker installed?)")?; + anyhow::ensure!(st.success(), "docker run failed"); + Ok(()) + } } - let exists = Command::new("multipass") - .args(["info", name]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !exists { - let st = Command::new("multipass") - .args([ - "launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image, - ]) - .status() - .context("multipass launch (is multipass installed?)")?; - anyhow::ensure!(st.success(), "multipass launch failed"); - } else { - let _ = Command::new("multipass").args(["start", name]).status(); - } - Ok(()) } /// Destroy ephemeral resources after stop. Multipass instance is purged; -/// Docker uses `--rm` so the container is already gone; Local is a no-op. +/// the Docker container is removed; Local is a no-op. pub fn teardown(backend: Backend, name: &str) { - if backend == Backend::Multipass { - let _ = Command::new("multipass") - .args(["delete", name, "--purge"]) - .status(); + match backend { + Backend::Multipass => { + let _ = Command::new("multipass").args(["delete", name, "--purge"]).status(); + } + Backend::Docker => { + let _ = Command::new("docker").args(["rm", "-f", name]).status(); + } + Backend::Local => {} } } -/// Build the shell command that launches the sandbox for a backend. -fn command_for(backend: Backend, name: &str, image: &str) -> CommandBuilder { +/// Build the shell command for a backend, running as unix user `run_user` +/// (empty = backend default). The container/VM is already up (see `prepare`). +fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder { match backend { Backend::Local => { let mut c = CommandBuilder::new("bash"); @@ -92,21 +107,106 @@ fn command_for(backend: Backend, name: &str, image: &str) -> CommandBuilder { c } Backend::Docker => { + let user = if run_user.is_empty() { "root" } else { run_user }; let mut c = CommandBuilder::new("docker"); - c.args([ - "run", "--rm", "-i", "--hostname", name, "-w", "/root", image, "bash", "-i", - ]); + c.args(["exec", "-it", "-u", user, name, "bash", "-il"]); c } Backend::Multipass => { - // Assumes the instance `name` was launched separately (P3b lifecycle). let mut c = CommandBuilder::new("multipass"); - c.args(["exec", name, "--", "bash", "-il"]); + if run_user.is_empty() { + c.args(["exec", name, "--", "bash", "-il"]); + } else { + // Login shell as the provisioned owner account (a real sudoer). + c.args(["exec", name, "--", "sudo", "-u", run_user, "-i"]); + } c } } } +/// Sanitize a coven display name into a safe unix username. +pub fn unix_name(name: &str) -> String { + let s: String = name + .to_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .take(31) + .collect(); + s.trim_start_matches(['-', '_']).to_string() +} + +fn mp(name: &str, args: &[&str]) { + let mut a = vec!["exec", name, "--"]; + a.extend_from_slice(args); + let _ = Command::new("multipass").args(a).status(); +} +fn dk(name: &str, args: &[&str]) { + let mut a = vec!["exec", name]; + a.extend_from_slice(args); + let _ = Command::new("docker").args(a).status(); +} + +/// Grant a Multipass user real *passwordless* sudo (group + sudoers.d drop-in) +/// so they're a usable superuser non-interactively. `u` is already unix-safe. +fn mp_grant_sudo(name: &str, u: &str) { + let script = format!( + "usermod -aG sudo {u}; printf '{u} ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/90-{u}; chmod 440 /etc/sudoers.d/90-{u}" + ); + mp(name, &["sudo", "bash", "-c", &script]); +} +fn mp_revoke_sudo(name: &str, u: &str) { + let script = format!("gpasswd -d {u} sudo 2>/dev/null; rm -f /etc/sudoers.d/90-{u}"); + mp(name, &["sudo", "bash", "-c", &script]); +} + +/// Provision a real unix account per coven member inside the VM/container and +/// make the owner a superuser (sudoer). Returns the unix user the shared shell +/// should run as. Blocking — call off the UI thread. +pub fn provision(backend: Backend, name: &str, owner: &str, members: &[String]) -> String { + let run = unix_name(owner); + match backend { + Backend::Multipass => { + for m in members { + let u = unix_name(m); + if !u.is_empty() { + mp(name, &["sudo", "useradd", "-m", "-s", "/bin/bash", &u]); + } + } + if !run.is_empty() { + mp_grant_sudo(name, &run); // owner = passwordless superuser + } + run + } + Backend::Docker => { + for m in members { + let u = unix_name(m); + if !u.is_empty() { + dk(name, &["useradd", "-m", "-s", "/bin/bash", &u]); + } + } + // Base images usually lack the sudo package; the shared shell runs as + // root (superuser) and drive-grant is the delegation mechanism. + "root".to_string() + } + Backend::Local => String::new(), + } +} + +/// Grant/revoke real sudo for a member's unix account (Multipass; sudo is +/// preinstalled). No-op for Docker (no sudo pkg) / Local. +pub fn set_sudo(backend: Backend, name: &str, user: &str, enable: bool) { + let u = unix_name(user); + if u.is_empty() || backend != Backend::Multipass { + return; + } + if enable { + mp_grant_sudo(name, &u); + } else { + mp_revoke_sudo(name, &u); + } +} + pub struct Sandbox { // Held for the PTY's lifetime (dropping it closes the terminal) + resize. #[allow(dead_code)] @@ -123,7 +223,7 @@ impl Sandbox { pub fn launch( backend: Backend, name: &str, - image: &str, + run_user: &str, rows: u16, cols: u16, out: mpsc::Sender>, @@ -138,7 +238,7 @@ impl Sandbox { }) .context("openpty")?; - let cmd = command_for(backend, name, image); + let cmd = command_for(backend, name, run_user); let child = pair .slave .spawn_command(cmd) @@ -203,7 +303,7 @@ mod tests { #[test] fn local_shell_pty_roundtrip() { let (tx, rx) = mpsc::channel(); - let mut sb = Sandbox::launch(Backend::Local, "test", "ubuntu:24.04", 24, 80, tx) + let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) .expect("launch local shell"); sb.write_input(b"echo HELLO_PTY_42\n").unwrap(); @@ -242,7 +342,7 @@ mod relay_tests { use base64::Engine; let (tx, rx) = mpsc::channel(); - let mut sb = Sandbox::launch(Backend::Local, "test", "ubuntu:24.04", 24, 80, tx) + let mut sb = Sandbox::launch(Backend::Local, "test", "", 24, 80, tx) .expect("launch"); sb.write_input(b"echo RELAY_MARKER_7\n").unwrap(); diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 1eda859..df86a18 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -116,10 +116,12 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th .iter() .map(|u| { let me = u.username == app.me; - // ⛧ owner/superuser · ◆ may drive · • member + // ⛧ owner · ⚡ sudoer (VM superuser) · ◆ may drive · • member let owner = app.owner.as_deref() == Some(u.username.as_str()); let mark = if owner { "⛧" + } else if app.sudoers.contains(&u.username) { + "⚡" } else if app.drivers.contains(&u.username) { "◆" } else {