feat(hh): real VM unix users + sudo delegation ⛧
Linux-style user permissions inside the sandbox (the original superuser ask):
- Backends are now persistent (docker run -d + exec; multipass instance) so the
broker can provision accounts and run the shell as a chosen user.
- sbx::provision(): create a real unix account per coven member at launch; the
OWNER becomes a passwordless superuser (sudo group + /etc/sudoers.d NOPASSWD
drop-in on multipass). The shared shell runs as the owner's account.
- /sudo <user> and /unsudo <user> (owner-only): real usermod + sudoers.d in the
VM — delegate/withdraw superuser. ACL frame carries sudoers; roster shows
⛧ owner · ⚡ sudoer · ◆ driver · • member.
Verified live on a real Multipass VM: shell runs as owner@vm with
'sudo -n whoami' == root; '/sudo member' gives member 'NOPASSWD: ALL';
teardown purges the instance. Docker provisions accounts + persistent
container (shell as root; sudo pkg absent so drive-grant is the delegation).
Tests: 7 cargo tests pass; clean build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1dfc614cc5
commit
0e269afce7
|
|
@ -65,7 +65,7 @@ pub enum Net {
|
||||||
SbxResize { rows: u16, cols: u16 },
|
SbxResize { rows: u16, cols: u16 },
|
||||||
SbxData(Vec<u8>),
|
SbxData(Vec<u8>),
|
||||||
SbxInput { from: String, bytes: Vec<u8> },
|
SbxInput { from: String, bytes: Vec<u8> },
|
||||||
Perm { owner: String, drivers: Vec<String> },
|
Perm { owner: String, drivers: Vec<String>, sudoers: Vec<String> },
|
||||||
Ft(ft::Ft),
|
Ft(ft::Ft),
|
||||||
Sys(String),
|
Sys(String),
|
||||||
Closed,
|
Closed,
|
||||||
|
|
@ -87,6 +87,8 @@ pub struct App {
|
||||||
pub driving: bool,
|
pub driving: bool,
|
||||||
pub owner: Option<String>,
|
pub owner: Option<String>,
|
||||||
pub drivers: std::collections::HashSet<String>,
|
pub drivers: std::collections::HashSet<String>,
|
||||||
|
/// Members whose VM unix account has sudo (superuser). Always includes owner.
|
||||||
|
pub sudoers: std::collections::HashSet<String>,
|
||||||
pub pending_offer: Option<ft::Offer>,
|
pub pending_offer: Option<ft::Offer>,
|
||||||
transfers: HashMap<String, Transfer>,
|
transfers: HashMap<String, Transfer>,
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +106,7 @@ impl App {
|
||||||
driving: false,
|
driving: false,
|
||||||
owner: None,
|
owner: None,
|
||||||
drivers: std::collections::HashSet::new(),
|
drivers: std::collections::HashSet::new(),
|
||||||
|
sudoers: std::collections::HashSet::new(),
|
||||||
pending_offer: None,
|
pending_offer: None,
|
||||||
transfers: HashMap::new(),
|
transfers: HashMap::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +161,7 @@ impl App {
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
self.owner = None;
|
self.owner = None;
|
||||||
self.drivers.clear();
|
self.drivers.clear();
|
||||||
|
self.sudoers.clear();
|
||||||
self.sys("⛧ sandbox dismissed");
|
self.sys("⛧ sandbox dismissed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,8 +176,9 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
|
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
|
||||||
Net::Perm { owner, drivers } => {
|
Net::Perm { owner, drivers, sudoers } => {
|
||||||
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
|
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
|
||||||
|
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
|
||||||
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
|
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
|
||||||
self.sys(format!("⛧ {owner} is the superuser (sandbox owner)"));
|
self.sys(format!("⛧ {owner} is the superuser (sandbox owner)"));
|
||||||
}
|
}
|
||||||
|
|
@ -183,8 +188,12 @@ impl App {
|
||||||
self.driving = false;
|
self.driving = false;
|
||||||
self.sys("⛧ your drive permission was revoked");
|
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.owner = Some(owner).filter(|o| !o.is_empty());
|
||||||
self.drivers = new;
|
self.drivers = new;
|
||||||
|
self.sudoers = sudo;
|
||||||
}
|
}
|
||||||
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
|
||||||
Net::Sys(t) => self.sys(t),
|
Net::Sys(t) => self.sys(t),
|
||||||
|
|
@ -230,7 +239,10 @@ fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_
|
||||||
|
|
||||||
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
|
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
|
||||||
let drivers: Vec<&String> = app.drivers.iter().collect();
|
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).
|
/// 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.owner = Some(app.me.clone());
|
||||||
app.drivers.clear();
|
app.drivers.clear();
|
||||||
app.drivers.insert(app.me.clone());
|
app.drivers.insert(app.me.clone());
|
||||||
|
app.sudoers.clear();
|
||||||
|
app.sudoers.insert(app.me.clone()); // owner = superuser
|
||||||
send_frame(&out_tx, &session.room, json!({
|
send_frame(&out_tx, &session.room, json!({
|
||||||
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
|
"_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 sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
||||||
*launching = true;
|
*launching = true;
|
||||||
app.sys(format!("summoning {} sandbox… (multipass boot can take ~30s)", backend.label()));
|
let members: Vec<String> = app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
spawn_launch(backend, image, rows, cols, pty_tx.clone(), broker_tx.clone(), app_tx.clone());
|
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") => {
|
Some("stop") => {
|
||||||
|
|
@ -556,6 +572,36 @@ fn handle_command(
|
||||||
}
|
}
|
||||||
_ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"),
|
_ => 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 <user>");
|
||||||
|
} 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 <user> (delegate VM superuser) | /unsudo <user>");
|
||||||
|
} 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") {
|
} else if let Some(rest) = line.strip_prefix("/grant") {
|
||||||
let target = rest.trim();
|
let target = rest.trim();
|
||||||
if !app.is_owner() {
|
if !app.is_owner() {
|
||||||
|
|
@ -585,9 +631,12 @@ fn handle_command(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_launch(
|
fn spawn_launch(
|
||||||
backend: sbx::Backend,
|
backend: sbx::Backend,
|
||||||
image: String,
|
image: String,
|
||||||
|
owner: String,
|
||||||
|
members: Vec<String>,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
pty_tx: UnboundedSender<Vec<u8>>,
|
pty_tx: UnboundedSender<Vec<u8>>,
|
||||||
|
|
@ -605,8 +654,15 @@ fn spawn_launch(
|
||||||
let _ = broker_tx.send(BrokerMsg::Failed);
|
let _ = broker_tx.send(BrokerMsg::Failed);
|
||||||
return;
|
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::<Vec<u8>>();
|
let (std_tx, std_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||||
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) => {
|
Ok(sb) => {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
while let Ok(b) = std_rx.recv() {
|
while let Ok(b) = std_rx.recv() {
|
||||||
|
|
|
||||||
|
|
@ -192,15 +192,18 @@ fn parse_perm(text: &str) -> Option<Net> {
|
||||||
if v["_perm"].as_str()? != "acl" {
|
if v["_perm"].as_str()? != "acl" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let drivers = v["drivers"]
|
let list = |key: &str| {
|
||||||
|
v[key]
|
||||||
.as_array()
|
.as_array()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter_map(|d| d.as_str().map(str::to_string))
|
.filter_map(|d| d.as_str().map(str::to_string))
|
||||||
.collect();
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
Some(Net::Perm {
|
Some(Net::Perm {
|
||||||
owner: v["owner"].as_str().unwrap_or("").to_string(),
|
owner: v["owner"].as_str().unwrap_or("").to_string(),
|
||||||
drivers,
|
drivers: list("drivers"),
|
||||||
|
sudoers: list("sudoers"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
142
hh/src/sbx.rs
142
hh/src/sbx.rs
|
|
@ -51,9 +51,9 @@ impl Backend {
|
||||||
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
|
/// thread (Multipass boots a real VM, ~20-30s). Idempotent: reuses an instance
|
||||||
/// that already exists.
|
/// that already exists.
|
||||||
pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||||
if backend != Backend::Multipass {
|
match backend {
|
||||||
return Ok(());
|
Backend::Local => Ok(()),
|
||||||
}
|
Backend::Multipass => {
|
||||||
let exists = Command::new("multipass")
|
let exists = Command::new("multipass")
|
||||||
.args(["info", name])
|
.args(["info", name])
|
||||||
.output()
|
.output()
|
||||||
|
|
@ -61,9 +61,7 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !exists {
|
if !exists {
|
||||||
let st = Command::new("multipass")
|
let st = Command::new("multipass")
|
||||||
.args([
|
.args(["launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image])
|
||||||
"launch", "--name", name, "--cpus", "1", "--memory", "1G", "--disk", "5G", image,
|
|
||||||
])
|
|
||||||
.status()
|
.status()
|
||||||
.context("multipass launch (is multipass installed?)")?;
|
.context("multipass launch (is multipass installed?)")?;
|
||||||
anyhow::ensure!(st.success(), "multipass launch failed");
|
anyhow::ensure!(st.success(), "multipass launch failed");
|
||||||
|
|
@ -72,19 +70,36 @@ pub fn prepare(backend: Backend, name: &str, image: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Destroy ephemeral resources after stop. Multipass instance is purged;
|
/// 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) {
|
pub fn teardown(backend: Backend, name: &str) {
|
||||||
if backend == Backend::Multipass {
|
match backend {
|
||||||
let _ = Command::new("multipass")
|
Backend::Multipass => {
|
||||||
.args(["delete", name, "--purge"])
|
let _ = Command::new("multipass").args(["delete", name, "--purge"]).status();
|
||||||
.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.
|
/// Build the shell command for a backend, running as unix user `run_user`
|
||||||
fn command_for(backend: Backend, name: &str, image: &str) -> CommandBuilder {
|
/// (empty = backend default). The container/VM is already up (see `prepare`).
|
||||||
|
fn command_for(backend: Backend, name: &str, run_user: &str) -> CommandBuilder {
|
||||||
match backend {
|
match backend {
|
||||||
Backend::Local => {
|
Backend::Local => {
|
||||||
let mut c = CommandBuilder::new("bash");
|
let mut c = CommandBuilder::new("bash");
|
||||||
|
|
@ -92,21 +107,106 @@ fn command_for(backend: Backend, name: &str, image: &str) -> CommandBuilder {
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
Backend::Docker => {
|
Backend::Docker => {
|
||||||
|
let user = if run_user.is_empty() { "root" } else { run_user };
|
||||||
let mut c = CommandBuilder::new("docker");
|
let mut c = CommandBuilder::new("docker");
|
||||||
c.args([
|
c.args(["exec", "-it", "-u", user, name, "bash", "-il"]);
|
||||||
"run", "--rm", "-i", "--hostname", name, "-w", "/root", image, "bash", "-i",
|
|
||||||
]);
|
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
Backend::Multipass => {
|
Backend::Multipass => {
|
||||||
// Assumes the instance `name` was launched separately (P3b lifecycle).
|
|
||||||
let mut c = CommandBuilder::new("multipass");
|
let mut c = CommandBuilder::new("multipass");
|
||||||
|
if run_user.is_empty() {
|
||||||
c.args(["exec", name, "--", "bash", "-il"]);
|
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
|
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 {
|
pub struct Sandbox {
|
||||||
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
// Held for the PTY's lifetime (dropping it closes the terminal) + resize.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
@ -123,7 +223,7 @@ impl Sandbox {
|
||||||
pub fn launch(
|
pub fn launch(
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
name: &str,
|
name: &str,
|
||||||
image: &str,
|
run_user: &str,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
out: mpsc::Sender<Vec<u8>>,
|
out: mpsc::Sender<Vec<u8>>,
|
||||||
|
|
@ -138,7 +238,7 @@ impl Sandbox {
|
||||||
})
|
})
|
||||||
.context("openpty")?;
|
.context("openpty")?;
|
||||||
|
|
||||||
let cmd = command_for(backend, name, image);
|
let cmd = command_for(backend, name, run_user);
|
||||||
let child = pair
|
let child = pair
|
||||||
.slave
|
.slave
|
||||||
.spawn_command(cmd)
|
.spawn_command(cmd)
|
||||||
|
|
@ -203,7 +303,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn local_shell_pty_roundtrip() {
|
fn local_shell_pty_roundtrip() {
|
||||||
let (tx, rx) = mpsc::channel();
|
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");
|
.expect("launch local shell");
|
||||||
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
sb.write_input(b"echo HELLO_PTY_42\n").unwrap();
|
||||||
|
|
||||||
|
|
@ -242,7 +342,7 @@ mod relay_tests {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel();
|
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");
|
.expect("launch");
|
||||||
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
sb.write_input(b"echo RELAY_MARKER_7\n").unwrap();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,12 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| {
|
.map(|u| {
|
||||||
let me = u.username == app.me;
|
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 owner = app.owner.as_deref() == Some(u.username.as_str());
|
||||||
let mark = if owner {
|
let mark = if owner {
|
||||||
"⛧"
|
"⛧"
|
||||||
|
} else if app.sudoers.contains(&u.username) {
|
||||||
|
"⚡"
|
||||||
} else if app.drivers.contains(&u.username) {
|
} else if app.drivers.contains(&u.username) {
|
||||||
"◆"
|
"◆"
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user