diff --git a/packages/contrib/quicksand-ubuntu/hatch_build.py b/packages/contrib/quicksand-ubuntu/hatch_build.py index 6f0b7af..fed02cf 100644 --- a/packages/contrib/quicksand-ubuntu/hatch_build.py +++ b/packages/contrib/quicksand-ubuntu/hatch_build.py @@ -42,9 +42,12 @@ def initialize(self, version: str, build_data: dict) -> None: image_path = images_dir / f"ubuntu-{distro_version}-{arch}.qcow2" dockerfile_path = Path(self.root) / "quicksand_ubuntu" / "docker" / "Dockerfile" - if not image_path.exists(): - self.app.display_info(f"Image not found: {image_path.name}, building...") - self._build_image(dockerfile_path, image_path) + # Always delegate to build_image: it reuses the cached qcow2 only when + # its sidecar hash matches the current Dockerfile + agent source, and + # rebuilds otherwise. Gating on mere existence here would package a + # stale image after an agent-only change. + self.app.display_info(f"Ensuring image is up to date: {image_path.name}") + self._build_image(dockerfile_path, image_path) self.app.display_info(f"Including image: {image_path}") diff --git a/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py b/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py index ed749e0..c560123 100644 --- a/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py +++ b/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py @@ -322,11 +322,19 @@ def _verify_windows_isolation(self, binary: Path, bin_dir: Path) -> None: "would break on hosts without it on PATH." ) - def set_platform_wheel_tag(self, build_data: dict) -> None: + def set_platform_wheel_tag(self, build_data: dict, bin_dir: Path | None = None) -> None: """Set build_data fields for a platform-specific py3-none wheel. On Windows ARM64, overrides the tag from ``win_amd64`` to ``win_arm64`` when native hardware is ARM64 (Python may report amd64 under emulation). + + On Linux, the manylinux level is derived from the actual glibc symbol + versions the bundled binaries require (via :func:`_linux_manylinux_tag`) + rather than hardcoded. The binaries link against the build runner's + glibc, so the tag must reflect their real floor. A hardcoded + ``manylinux_2_17`` lets pip install wheels that then fail to load on + hosts with an older glibc than the runner. ``bin_dir`` (the directory + holding the bundled binaries) is required for Linux wheels. """ build_data["pure_python"] = False platform_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_") @@ -349,12 +357,78 @@ def set_platform_wheel_tag(self, build_data: dict) -> None: except Exception: pass - # PyPI requires manylinux tags for Linux wheels (PEP 600) if platform_tag.startswith("linux_"): - platform_tag = platform_tag.replace("linux_", "manylinux_2_17_", 1) + if bin_dir is None: + raise RuntimeError( + "set_platform_wheel_tag requires bin_dir for Linux wheels to " + "derive the manylinux tag from the bundled binaries' glibc " + "requirement." + ) + platform_tag = self._linux_manylinux_tag(bin_dir, platform_tag) build_data["tag"] = f"py3-none-{platform_tag}" + def _linux_manylinux_tag(self, bin_dir: Path, platform_tag: str) -> str: + """Derive the manylinux platform tag from the bundled ELF binaries. + + Scans every ELF file under ``bin_dir`` for the glibc symbol versions it + references and asks auditwheel which manylinux policy that implies. The + result (e.g. ``manylinux_2_38_aarch64``) is the lowest manylinux level + the binaries can actually run on. auditwheel is a hard build dependency + on Linux; if it or the analysis fails we raise rather than guess, since + a wrong tag produces wheels that crash on load instead of being + rejected at install time. + """ + from collections import defaultdict + + # Linux-only build deps, absent from the dev venv on other platforms. + from auditwheel.architecture import Architecture # ty: ignore[unresolved-import] + from auditwheel.elfutils import elf_find_versioned_symbols # ty: ignore[unresolved-import] + from auditwheel.libc import Libc # ty: ignore[unresolved-import] + from auditwheel.policy import WheelPolicies # ty: ignore[unresolved-import] + from elftools.common.exceptions import ELFError # ty: ignore[unresolved-import] + from elftools.elf.elffile import ELFFile # ty: ignore[unresolved-import] + + arch_name = platform_tag[len("linux_") :] + try: + arch = Architecture(arch_name) + except ValueError as exc: + raise RuntimeError( + f"Unknown architecture {arch_name!r} for manylinux tagging." + ) from exc + + versioned_symbols: dict[str, set[str]] = defaultdict(set) + elf_count = 0 + for path in sorted(bin_dir.rglob("*")): + if path.is_symlink() or not path.is_file(): + continue + try: + with path.open("rb") as fh: + if fh.read(4) != b"\x7fELF": + continue + fh.seek(0) + elf = ELFFile(fh) + for soname, version in elf_find_versioned_symbols(elf): + versioned_symbols[soname].add(version) + except (ELFError, OSError): + continue + elf_count += 1 + + if elf_count == 0: + raise RuntimeError(f"No ELF binaries found under {bin_dir} to derive a glibc tag from.") + + policies = WheelPolicies(libc=Libc.GLIBC, arch=arch) + policy_name = policies.versioned_symbols_policy(dict(versioned_symbols)).name + if not policy_name.startswith("manylinux_"): + raise RuntimeError( + f"Bundled binaries require a glibc newer than any manylinux policy " + f"auditwheel knows ({policy_name!r}). Upgrade auditwheel or build " + f"against an older glibc. Required symbol versions: " + f"{dict(versioned_symbols)}" + ) + self.app.display_info(f"Derived manylinux tag from glibc usage: {policy_name}") + return policy_name + def force_include_bin_dir(self, bin_dir: Path, root: Path, build_data: dict) -> None: """Add all files in bin_dir to the wheel's force_include.""" force_include = build_data.setdefault("force_include", {}) diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py b/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py index 3eab1b0..dafe5ed 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py @@ -31,6 +31,28 @@ def get_agent_source_dir() -> Path: return AGENT_SOURCE_DIR +def _agent_source_hash() -> str: + """Hash of the Rust agent source (Cargo manifests + all .rs files). + + Folded into the image cache key so that changes to the agent — which the + Dockerfile compiles but does not itself reference — invalidate cached + images. Without this, an agent-only change reuses a stale qcow2. + """ + h = hashlib.sha256() + if AGENT_SOURCE_DIR.exists(): + paths = sorted( + p + for p in AGENT_SOURCE_DIR.rglob("*") + if p.is_file() + and "target" not in p.relative_to(AGENT_SOURCE_DIR).parts + and (p.suffix == ".rs" or p.name in ("Cargo.toml", "Cargo.lock")) + ) + for p in paths: + h.update(p.relative_to(AGENT_SOURCE_DIR).as_posix().encode()) + h.update(p.read_bytes()) + return h.hexdigest() + + def build_image( dockerfile: str | Path, output_path: Path | None = None, @@ -79,15 +101,35 @@ def build_image( dockerfile_content = dockerfile_path.read_text() context_dir = dockerfile_path.parent - # Compute hash for caching - content_hash = hashlib.sha256(dockerfile_content.encode()).hexdigest()[:16] + # Compute hash for caching. Includes the agent source because the + # Dockerfile compiles the agent from a build-context copy that the + # Dockerfile text doesn't reference — so agent changes must bust the cache. + content_hash = hashlib.sha256( + dockerfile_content.encode() + _agent_source_hash().encode() + ).hexdigest()[:16] + explicit_output = output_path is not None if output_path is None: output_path = cache / f"custom-{content_hash}.qcow2" + # A sidecar records the content hash of the inputs (Dockerfile + agent + # source) that produced ``output_path``. A caller-supplied output path has + # a fixed name, so without the sidecar a stale image (e.g. built before an + # agent change) would be silently reused. The default ``custom-`` + # path already encodes the hash in its name, so existence alone proves + # freshness there. + hash_sidecar = output_path.with_name(output_path.name + ".buildhash") + # Check cache if output_path.exists(): - if force: + if explicit_output: + cached_hash = hash_sidecar.read_text().strip() if hash_sidecar.exists() else None + stale = cached_hash != content_hash + else: + stale = False + if force or stale: + reason = "forced" if force else "inputs changed" + log.info("Rebuilding image (%s): %s", reason, output_path) output_path.unlink() else: log.info("Using cached image: %s", output_path) @@ -128,6 +170,8 @@ def build_image( _remove_docker_image(tag) log.info("[5/5] Done!") + # Record the inputs' hash so a later build can detect staleness. + hash_sidecar.write_text(content_hash) final_size_mb = output_path.stat().st_size / (1024 * 1024) log.info("Output: %s (%.1f MB)", output_path, final_size_mb) diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml index 0c3b3b1..074cb1a 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml @@ -23,6 +23,8 @@ tokio-stream = "0.1" # single open handle and a buffered `tokio::fs::File` seeks when reads and # writes interleave (ESPIPE on a non-seekable char device), so the transport # drives one non-blocking fd through `tokio::io::unix::AsyncFd` instead. +# Also used for privilege drop (setuid/setgid/initgroups) and chown/kill in +# multi-user mode. libc = "0.2" [profile.release] diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs index 570a775..e197386 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs @@ -16,13 +16,16 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::{ convert::Infallible, + ffi::CString, fs, io::Write, net::SocketAddr, + os::unix::fs::{MetadataExt, PermissionsExt}, + os::unix::process::CommandExt, process::{Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, Mutex, }, time::Duration, }; @@ -93,12 +96,32 @@ struct ExecuteRequest { /// When true, reject all other execute requests while this one is running. #[serde(default)] exclusive: bool, + /// Optional OS user to run the command as. When set, the command is + /// executed with that user's uid/gid/groups and HOME, defaulting cwd to + /// the user's home directory. + #[serde(default)] + user: Option, } fn default_timeout() -> f64 { 30.0 } +#[derive(Deserialize)] +struct UserRequest { + name: String, + /// On delete, also remove the user's home directory. + #[serde(default)] + remove_home: bool, +} + +#[derive(Serialize)] +struct UserCreatedResponse { + uid: u32, + gid: u32, + home: String, +} + #[derive(Serialize)] struct ExecuteResponse { stdout: String, @@ -141,6 +164,231 @@ fn verify_token(headers: &axum::http::HeaderMap, expected: &str) -> Result<(), ( } } +// ============================================================================ +// Multi-user support +// ============================================================================ +// +// Users are managed by editing the standard POSIX account files directly +// (/etc/passwd, /etc/group, /etc/shadow). This keeps the behaviour identical +// across every distro (Alpine, Ubuntu, ...) because those formats are +// standardised and every minimal guest defaults to the `files` nsswitch +// backend — no dependency on distro-specific `adduser`/`useradd` tools. + +/// uid/gid range for quicksand-managed users. +const UID_MIN: u32 = 1000; +const UID_MAX: u32 = 60000; + +/// Serializes account-file mutations so concurrent create/delete requests +/// (the agent multiplexes requests) can't produce a torn /etc/passwd write. +static USER_MGMT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Clone)] +struct PwEntry { + uid: u32, + gid: u32, + home: String, +} + +/// Look up an existing user in /etc/passwd. Returns None if absent. +fn lookup_user(name: &str) -> Option { + let content = fs::read_to_string("/etc/passwd").ok()?; + for line in content.lines() { + let f: Vec<&str> = line.split(':').collect(); + if f.len() >= 7 && f[0] == name { + return Some(PwEntry { + uid: f[2].parse().ok()?, + gid: f[3].parse().ok()?, + home: f[5].to_string(), + }); + } + } + None +} + +/// Validate a username to prevent injection into the colon/newline-delimited +/// account files. POSIX-portable subset: starts with [a-z_], then [a-z0-9_-]. +fn valid_username(name: &str) -> bool { + !name.is_empty() + && name.len() <= 32 + && name.bytes().enumerate().all(|(i, b)| match b { + b'a'..=b'z' | b'_' => true, + b'0'..=b'9' | b'-' if i > 0 => true, + _ => false, + }) +} + +/// Highest id in [UID_MIN, UID_MAX) across passwd+group, plus one. +fn next_free_id() -> u32 { + let mut max = UID_MIN - 1; + for (path, field) in [("/etc/passwd", 2usize), ("/etc/group", 2usize)] { + if let Ok(content) = fs::read_to_string(path) { + for line in content.lines() { + let f: Vec<&str> = line.split(':').collect(); + if f.len() > field { + if let Ok(id) = f[field].parse::() { + if (UID_MIN..UID_MAX).contains(&id) && id > max { + max = id; + } + } + } + } + } + } + max + 1 +} + +fn append_line(path: &str, line: &str) -> std::io::Result<()> { + let mut f = fs::OpenOptions::new().append(true).create(true).open(path)?; + f.write_all(line.as_bytes())?; + f.write_all(b"\n") +} + +fn remove_lines_for_user(path: &str, name: &str) -> std::io::Result<()> { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return Ok(()), // file may not exist (e.g. shadow); nothing to do + }; + let prefix = format!("{}:", name); + let mut out: String = content + .lines() + .filter(|l| !l.starts_with(&prefix)) + .collect::>() + .join("\n"); + if !out.is_empty() { + out.push('\n'); + } + fs::write(path, out) +} + +/// SIGKILL every process owned by `uid` (each /proc/ dir is owned by the +/// process's real uid). +fn kill_user_processes(uid: u32) { + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + let fname = entry.file_name(); + let pid = match fname.to_string_lossy().parse::() { + Ok(p) => p, + Err(_) => continue, + }; + if let Ok(meta) = fs::metadata(format!("/proc/{}", pid)) { + if meta.uid() == uid { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + } + } +} + +/// Create a new user account (distro-agnostic, native file manipulation). +fn create_user(name: &str) -> Result { + let _guard = USER_MGMT_LOCK.lock().unwrap(); + + if !valid_username(name) { + return Err(format!("Invalid username: {}", name)); + } + if lookup_user(name).is_some() { + return Err(format!("User already exists: {}", name)); + } + + let id = next_free_id(); + if id >= UID_MAX { + return Err("No free uid available".to_string()); + } + let home = format!("/home/{}", name); + + // group: name:x:gid: + append_line("/etc/group", &format!("{}:x:{}:", name, id)) + .map_err(|e| format!("write /etc/group: {}", e))?; + // passwd: name:x:uid:gid:gecos:home:shell (gecos empty; /bin/sh is universal) + append_line( + "/etc/passwd", + &format!("{}:x:{}:{}::{}:/bin/sh", name, id, id, home), + ) + .map_err(|e| format!("write /etc/passwd: {}", e))?; + // shadow: locked password (`!`); we never password-auth, only setuid. + let _ = append_line("/etc/shadow", &format!("{}:!::0:99999:7:::", name)); + + fs::create_dir_all(&home).map_err(|e| format!("create {}: {}", home, e))?; + let c_home = CString::new(home.as_str()).map_err(|e| e.to_string())?; + unsafe { + if libc::chown(c_home.as_ptr(), id as _, id as _) != 0 { + return Err(format!("chown {}: {}", home, std::io::Error::last_os_error())); + } + } + fs::set_permissions(&home, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("chmod {}: {}", home, e))?; + + Ok(PwEntry { uid: id, gid: id, home }) +} + +/// Delete a user: kill its processes, strip account-file entries, optionally +/// remove its home directory. +fn delete_user(name: &str, remove_home: bool) -> Result<(), String> { + let _guard = USER_MGMT_LOCK.lock().unwrap(); + + if !valid_username(name) { + return Err(format!("Invalid username: {}", name)); + } + let pw = lookup_user(name).ok_or_else(|| format!("No such user: {}", name))?; + + kill_user_processes(pw.uid); + + remove_lines_for_user("/etc/passwd", name).map_err(|e| format!("edit /etc/passwd: {}", e))?; + remove_lines_for_user("/etc/group", name).map_err(|e| format!("edit /etc/group: {}", e))?; + let _ = remove_lines_for_user("/etc/shadow", name); + + if remove_home { + let _ = fs::remove_dir_all(&pw.home); + } + Ok(()) +} + +/// Resolve an optional username into a `PwEntry`, returning an error string if +/// the user is requested but doesn't exist. +fn resolve_user(user: &Option) -> Result, String> { + match user { + None => Ok(None), + Some(u) => match lookup_user(u) { + Some(pw) => Ok(Some((u.clone(), pw))), + None => Err(format!("No such user: {}", u)), + }, + } +} + +/// Configure a Command (std or tokio) to run as `name`/`pw`: set HOME/USER and +/// register a pre_exec hook that, in order, joins the user's supplementary +/// groups then drops gid and uid. Everything happens while still root in the +/// forked child, before exec — ordering is explicit so the privilege drop is +/// correct regardless of std internals. +macro_rules! configure_user { + ($cmd:expr, $pw:expr, $name:expr) => {{ + let pw_ref: &PwEntry = $pw; + let nm: &str = $name; + let uid = pw_ref.uid; + let gid = pw_ref.gid; + let name_c = CString::new(nm).expect("validated username"); + $cmd.env("HOME", &pw_ref.home).env("USER", nm).env("LOGNAME", nm); + unsafe { + $cmd.pre_exec(move || { + // `as _` adapts to the target's libc types (e.g. initgroups' + // basegroup is gid_t on Linux but c_int on macOS). + if libc::initgroups(name_c.as_ptr(), gid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::setgid(gid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::setuid(uid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + }}; +} + // ============================================================================ // Handlers // ============================================================================ @@ -165,6 +413,13 @@ async fn execute( return e.into_response(); } + // Resolve the target user (if any) before touching the exclusive lock so a + // bad username can't leave the lock claimed. + let user_pw = match resolve_user(&req.user) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + }; + // Reject if an exclusive command is already running. if state.exclusive_busy.load(Ordering::SeqCst) { return ( @@ -203,6 +458,11 @@ async fn execute( if let Some(cwd) = &req.cwd { cmd.current_dir(cwd); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } cmd.output() @@ -244,6 +504,12 @@ async fn execute_stream( return e.into_response(); } + // Resolve the target user (if any) before touching the exclusive lock. + let user_pw = match resolve_user(&req.user) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + }; + // Reject if an exclusive command is already running. if state.exclusive_busy.load(Ordering::SeqCst) { return ( @@ -286,6 +552,11 @@ async fn execute_stream( if let Some(cwd) = &req.cwd { cmd.current_dir(cwd); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } let mut child = match cmd.spawn() { @@ -379,6 +650,42 @@ async fn ping( .into_response() } +async fn create_user_handler( + State(state): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + if let Err(e) = verify_token(&headers, &state.token) { + return e.into_response(); + } + match create_user(&req.name) { + Ok(pw) => ( + StatusCode::OK, + Json(UserCreatedResponse { + uid: pw.uid, + gid: pw.gid, + home: pw.home, + }), + ) + .into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + } +} + +async fn delete_user_handler( + State(state): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + if let Err(e) = verify_token(&headers, &state.token) { + return e.into_response(); + } + match delete_user(&req.name, req.remove_home) { + Ok(()) => (StatusCode::OK, Json(serde_json::json!({"removed": true}))).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + } +} + // ============================================================================ // Shared command execution (used by both HTTP and virtio-serial transports) // ============================================================================ @@ -389,7 +696,23 @@ struct ExecResult { exit_code: i32, } -async fn run_command(command: &str, timeout_secs: f64, cwd: Option<&str>) -> ExecResult { +async fn run_command( + command: &str, + timeout_secs: f64, + cwd: Option<&str>, + user: Option<&str>, +) -> ExecResult { + let user_pw = match resolve_user(&user.map(|s| s.to_string())) { + Ok(v) => v, + Err(e) => { + return ExecResult { + stdout: String::new(), + stderr: e, + exit_code: -1, + } + } + }; + let timeout_duration = Duration::from_secs_f64(timeout_secs); let result = timeout(timeout_duration, async { @@ -399,6 +722,11 @@ async fn run_command(command: &str, timeout_secs: f64, cwd: Option<&str>) -> Exe cmd.stderr(Stdio::piped()); if let Some(dir) = cwd { cmd.current_dir(dir); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } cmd.output() }) @@ -614,6 +942,7 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(); let timeout_secs = params.get("timeout").and_then(|v| v.as_f64()).unwrap_or(30.0); let cwd = params.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let user = params.get("user").and_then(|v| v.as_str()).map(|s| s.to_string()); let is_exclusive = params.get("exclusive").and_then(|v| v.as_bool()).unwrap_or(false); if exclusive_busy.load(Ordering::SeqCst) { @@ -632,7 +961,8 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let tx = tx.clone(); let exclusive_busy = Arc::clone(&exclusive_busy); tokio::spawn(async move { - let result = run_command(&command, timeout_secs, cwd.as_deref()).await; + let result = + run_command(&command, timeout_secs, cwd.as_deref(), user.as_deref()).await; if is_exclusive { exclusive_busy.store(false, Ordering::SeqCst); @@ -652,8 +982,19 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(); let timeout_secs = params.get("timeout").and_then(|v| v.as_f64()).unwrap_or(30.0); let cwd = params.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let user = params.get("user").and_then(|v| v.as_str()).map(|s| s.to_string()); let is_exclusive = params.get("exclusive").and_then(|v| v.as_bool()).unwrap_or(false); + // Resolve the target user before claiming the exclusive lock. + let user_pw = match resolve_user(&user) { + Ok(v) => v, + Err(e) => { + send_frame(&tx, serde_json::json!({"id": id, "stream": "stderr", "data": format!("{}\n", e)})); + send_frame(&tx, serde_json::json!({"id": id, "stream": "exit", "exit_code": -1})); + continue; + } + }; + if exclusive_busy.load(Ordering::SeqCst) { send_frame(&tx, serde_json::json!({"id": id, "error": {"message": "Exclusive command in progress"}})); continue; @@ -679,6 +1020,11 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { cmd.stderr(Stdio::piped()); if let Some(dir) = &cwd { cmd.current_dir(dir); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } let mut child = match cmd.spawn() { @@ -738,6 +1084,23 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { } }); } + "create_user" if authenticated => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let resp = match create_user(name) { + Ok(pw) => serde_json::json!({"id": id, "result": {"uid": pw.uid, "gid": pw.gid, "home": pw.home}}), + Err(e) => serde_json::json!({"id": id, "error": {"message": e}}), + }; + send_frame(&tx, resp); + } + "delete_user" if authenticated => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let remove_home = params.get("remove_home").and_then(|v| v.as_bool()).unwrap_or(false); + let resp = match delete_user(name, remove_home) { + Ok(()) => serde_json::json!({"id": id, "result": {"removed": true}}), + Err(e) => serde_json::json!({"id": id, "error": {"message": e}}), + }; + send_frame(&tx, resp); + } _ if !authenticated => { send_frame(&tx, serde_json::json!({"id": id, "error": {"message": "Not authenticated"}})); } @@ -808,6 +1171,8 @@ async fn main() { .route("/authenticate", post(authenticate)) .route("/execute", post(execute)) .route("/execute_stream", post(execute_stream)) + .route("/create_user", post(create_user_handler)) + .route("/delete_user", post(delete_user_handler)) .route("/ping", get(ping)) .with_state(state); diff --git a/packages/quicksand-core/quicksand_core/__init__.py b/packages/quicksand-core/quicksand_core/__init__.py index f265cf4..964b883 100644 --- a/packages/quicksand-core/quicksand_core/__init__.py +++ b/packages/quicksand-core/quicksand_core/__init__.py @@ -52,7 +52,7 @@ get_runtime, is_runtime_available, ) -from .sandbox import ExecuteResult, Sandbox, SandboxConfig, SandboxConfigParams +from .sandbox import ExecuteResult, Sandbox, SandboxConfig, SandboxConfigParams, SandboxUser __all__ = [ # noqa: RUF022 # Types module alias @@ -66,6 +66,7 @@ "MountType", "PortForward", "ExecuteResult", + "SandboxUser", # Boot timing "BootTiming", # Save diff --git a/packages/quicksand-core/quicksand_core/_types.py b/packages/quicksand-core/quicksand_core/_types.py index a8ab9a1..fd6bfd7 100644 --- a/packages/quicksand-core/quicksand_core/_types.py +++ b/packages/quicksand-core/quicksand_core/_types.py @@ -237,6 +237,8 @@ class QuicksandGuestAgentMethod(StrEnum): EXECUTE_STREAM = "execute_stream" PING = "ping" AUTHENTICATE = "authenticate" + CREATE_USER = "create_user" + DELETE_USER = "delete_user" # ============================================================================= @@ -404,6 +406,7 @@ class ExecuteParams: shell: str cwd: str | None = None exclusive: bool = False + user: str | None = None @dataclass diff --git a/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py b/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py index fa17c9e..5884ed3 100644 --- a/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py +++ b/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py @@ -185,6 +185,8 @@ async def send_request( endpoint_map = { QuicksandGuestAgentMethod.EXECUTE: "/execute", QuicksandGuestAgentMethod.PING: "/ping", + QuicksandGuestAgentMethod.CREATE_USER: "/create_user", + QuicksandGuestAgentMethod.DELETE_USER: "/delete_user", } endpoint = endpoint_map.get(method) diff --git a/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py b/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py index cfc09e4..bd780d4 100644 --- a/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py +++ b/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py @@ -294,6 +294,8 @@ async def send_request( method_map = { QuicksandGuestAgentMethod.EXECUTE: "execute", QuicksandGuestAgentMethod.PING: "ping", + QuicksandGuestAgentMethod.CREATE_USER: "create_user", + QuicksandGuestAgentMethod.DELETE_USER: "delete_user", } method_name = method_map.get(method) if method_name is None: diff --git a/packages/quicksand-core/quicksand_core/sandbox/__init__.py b/packages/quicksand-core/quicksand_core/sandbox/__init__.py index 16530b9..5cff96a 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/__init__.py +++ b/packages/quicksand-core/quicksand_core/sandbox/__init__.py @@ -1,6 +1,7 @@ """Sandbox package — re-exports the public surface.""" from .._types import ExecuteResult, SandboxConfig, SandboxConfigParams +from ._execution import SandboxUser from ._sandbox import Sandbox -__all__ = ["ExecuteResult", "Sandbox", "SandboxConfig", "SandboxConfigParams"] +__all__ = ["ExecuteResult", "Sandbox", "SandboxConfig", "SandboxConfigParams", "SandboxUser"] diff --git a/packages/quicksand-core/quicksand_core/sandbox/_execution.py b/packages/quicksand-core/quicksand_core/sandbox/_execution.py index c0472ab..35fe2af 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_execution.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_execution.py @@ -36,6 +36,7 @@ async def execute( on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, exclusive: bool = False, + user: str | None = None, ) -> ExecuteResult: """ Execute a shell command inside the sandbox. @@ -55,6 +56,11 @@ async def execute( exclusive: If True, the guest agent will reject other requests while this command is running. Used for system commands like sync/fstrim that need exclusive access. + user: Optional OS user to run the command as. The command runs + with that user's uid/gid/groups and HOME, defaulting cwd to + the user's home directory. The user must already exist (see + ``create_user``). Prefer obtaining a ``SandboxUser`` via + ``create_user`` and calling ``.execute()`` on it. Returns: ExecuteResult with stdout, stderr, and exit_code. @@ -63,7 +69,7 @@ async def execute( raise RuntimeError("Sandbox is not running") params = ExecuteParams( - command=command, timeout=timeout, shell=shell, cwd=cwd, exclusive=exclusive + command=command, timeout=timeout, shell=shell, cwd=cwd, exclusive=exclusive, user=user ) params_dict = {k: v for k, v in asdict(params).items() if v is not None} @@ -90,3 +96,108 @@ async def execute( result = ExecuteResponseResult(**response["result"]) return ExecuteResult(stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code) + + async def create_user(self, name: str) -> SandboxUser: + """Create an OS user in the sandbox and return a handle scoped to it. + + Distro-agnostic: the guest agent creates the account by writing the + standard POSIX files (``/etc/passwd``, ``/etc/group``, ``/etc/shadow``), + so it behaves identically on Alpine, Ubuntu, and any other guest with + no dependency on distro-specific ``adduser``/``useradd`` tools. + + Multiple users share one VM, giving logical per-user isolation + (separate ``$HOME``, ownership, credentials) without the memory cost of + a separate VM per user. + + Args: + name: Username. Must match ``[a-z_][a-z0-9_-]*`` (max 32 chars). + + Returns: + A ``SandboxUser`` whose ``execute`` runs commands as this user. + + Raises: + RuntimeError: If the sandbox is not running, or creation fails + (e.g. invalid name or the user already exists). + """ + if not self.is_running: + raise RuntimeError("Sandbox is not running") + response = await self._send_request(QuicksandGuestAgentMethod.CREATE_USER, {"name": name}) + if "error" in response: + message = response["error"].get("message", "unknown error") + raise RuntimeError(f"Failed to create user {name!r}: {message}") + result = response["result"] + return SandboxUser(self, name, uid=result["uid"], gid=result["gid"], home=result["home"]) + + async def delete_user(self, name: str, *, remove_home: bool = True) -> None: + """Delete an OS user from the sandbox. + + Kills the user's running processes, removes the account entries, and + (by default) deletes the home directory. + + Args: + name: Username to delete. + remove_home: If True (default), also remove ``/home/``. + + Raises: + RuntimeError: If the sandbox is not running, or deletion fails. + """ + if not self.is_running: + raise RuntimeError("Sandbox is not running") + response = await self._send_request( + QuicksandGuestAgentMethod.DELETE_USER, + {"name": name, "remove_home": remove_home}, + ) + if "error" in response: + message = response["error"].get("message", "unknown error") + raise RuntimeError(f"Failed to delete user {name!r}: {message}") + + +class SandboxUser: + """A handle to an OS user inside a running sandbox. + + Returned by :meth:`Sandbox.create_user`. Its :meth:`execute` runs commands + as this user (uid/gid/supplementary groups and ``HOME``), defaulting the + working directory to the user's home. It is a thin view over the sandbox's + single control channel — every user multiplexes over the same agent + connection, so there is no per-user VM overhead. + """ + + def __init__( + self, + sandbox: _ExecutionMixin, + name: str, + *, + uid: int, + gid: int, + home: str, + ): + self._sandbox = sandbox + self.name = name + self.uid = uid + self.gid = gid + self.home = home + + async def execute( + self, + command: str, + timeout: float = Timeouts.GUEST_AGENT_REQUEST, + cwd: str | None = None, + shell: str = GuestCommands.SHELL, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + exclusive: bool = False, + ) -> ExecuteResult: + """Execute a command as this user. See :meth:`Sandbox.execute`.""" + return await self._sandbox.execute( + command, + timeout=timeout, + cwd=cwd, + shell=shell, + on_stdout=on_stdout, + on_stderr=on_stderr, + exclusive=exclusive, + user=self.name, + ) + + def __repr__(self) -> str: + return f"SandboxUser(name={self.name!r}, uid={self.uid}, home={self.home!r})" diff --git a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py index 63ed8d6..9714407 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py @@ -77,6 +77,7 @@ async def execute( on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, exclusive: bool = False, + user: str | None = None, ) -> ExecuteResult: ... async def save( diff --git a/packages/quicksand-qemu/hatch_build.py b/packages/quicksand-qemu/hatch_build.py index 3cac89b..b1ed7df 100644 --- a/packages/quicksand-qemu/hatch_build.py +++ b/packages/quicksand-qemu/hatch_build.py @@ -1192,4 +1192,4 @@ def initialize(self, version: str, build_data: dict) -> None: # Mark as platform-specific wheel if version != "editable": - bundler.set_platform_wheel_tag(build_data) + bundler.set_platform_wheel_tag(build_data, bin_dir=bin_dir) diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index fb94f16..afcdf7d 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -8,7 +8,13 @@ license = "MIT" dependencies = [] [build-system] -requires = ["hatchling", "quicksand-build-tools>=0.5.3,<0.6.0"] +requires = [ + "hatchling", + "quicksand-build-tools>=0.5.3,<0.6.0", + # auditwheel derives the wheel's manylinux tag from the bundled binaries' + # glibc requirement on Linux. It pulls in pyelftools for ELF inspection. + "auditwheel; sys_platform == 'linux'", +] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/packages/quicksand/quicksand/__init__.py b/packages/quicksand/quicksand/__init__.py index 0461b9f..4eb79b9 100644 --- a/packages/quicksand/quicksand/__init__.py +++ b/packages/quicksand/quicksand/__init__.py @@ -52,6 +52,7 @@ async def main(): Sandbox, SandboxConfig, SandboxConfigParams, + SandboxUser, SaveManifest, detect_accelerator, ensure_runtime, @@ -109,6 +110,7 @@ def __repr__(self): "Sandbox", "SandboxConfig", "SandboxConfigParams", + "SandboxUser", "Mount", "MountType", "PortForward", diff --git a/tests/unit/test_checkpoint_ops.py b/tests/unit/test_checkpoint_ops.py index 18125ef..dfe1b39 100644 --- a/tests/unit/test_checkpoint_ops.py +++ b/tests/unit/test_checkpoint_ops.py @@ -206,6 +206,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return MagicMock(stdout="", stderr="", exit_code=0) @@ -372,6 +373,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return MagicMock(stdout="", stderr="", exit_code=0) diff --git a/tests/unit/test_dev_build.py b/tests/unit/test_dev_build.py index 9351029..4a744c9 100644 --- a/tests/unit/test_dev_build.py +++ b/tests/unit/test_dev_build.py @@ -157,10 +157,15 @@ def test_dockerfile_path_input(self, tmp_dir, cache_dir): dockerfile_path = tmp_dir / "Dockerfile" dockerfile_path.write_text("FROM alpine:3.20\n") - # Create cached image + # Create cached image. The cache key covers the Dockerfile plus the + # agent source the build copies into the context. import hashlib - content_hash = hashlib.sha256(b"FROM alpine:3.20\n").hexdigest()[:16] + from quicksand_image_tools.build import _agent_source_hash + + content_hash = hashlib.sha256( + b"FROM alpine:3.20\n" + _agent_source_hash().encode() + ).hexdigest()[:16] cached_image = cache_dir / f"custom-{content_hash}.qcow2" cached_image.touch() diff --git a/tests/unit/test_disk.py b/tests/unit/test_disk.py index b043d44..6f50577 100644 --- a/tests/unit/test_disk.py +++ b/tests/unit/test_disk.py @@ -54,6 +54,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return self._execute_fn(command, timeout) diff --git a/tests/unit/test_mounts.py b/tests/unit/test_mounts.py index b269b48..af3b01f 100644 --- a/tests/unit/test_mounts.py +++ b/tests/unit/test_mounts.py @@ -58,6 +58,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return self._execute_fn(command, timeout) diff --git a/tests/unit/test_multiuser.py b/tests/unit/test_multiuser.py new file mode 100644 index 0000000..4427ab8 --- /dev/null +++ b/tests/unit/test_multiuser.py @@ -0,0 +1,100 @@ +"""Unit tests for multi-user support (SandboxUser, create/delete, exec routing). + +These exercise the host-side wiring with a mocked agent client, so no VM boots. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from quicksand_core import Sandbox, SandboxUser +from quicksand_core._types import QuicksandGuestAgentMethod + + +def _running_sandbox(send_request: AsyncMock) -> Sandbox: + """A Sandbox marked running with a mocked agent transport.""" + sb = Sandbox(image="alpine") + sb._is_running = True + sb._process_manager = MagicMock(is_running=True) + client = AsyncMock() + client.send_request = send_request + sb._agent_client = client + return sb + + +@pytest.mark.asyncio +async def test_execute_threads_user_into_params(): + send = AsyncMock(return_value={"result": {"stdout": "alice", "stderr": "", "exit_code": 0}}) + sb = _running_sandbox(send) + + result = await sb.execute("whoami", user="alice") + + assert result.stdout == "alice" + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.EXECUTE + assert params["user"] == "alice" + + +@pytest.mark.asyncio +async def test_execute_omits_user_when_none(): + send = AsyncMock(return_value={"result": {"stdout": "", "stderr": "", "exit_code": 0}}) + sb = _running_sandbox(send) + + await sb.execute("true") + + _, params, *_ = send.call_args.args + assert "user" not in params # None fields are dropped before sending + + +@pytest.mark.asyncio +async def test_create_user_returns_handle_and_routes(): + send = AsyncMock(return_value={"result": {"uid": 1000, "gid": 1000, "home": "/home/alice"}}) + sb = _running_sandbox(send) + + user = await sb.create_user("alice") + + assert isinstance(user, SandboxUser) + assert (user.name, user.uid, user.gid, user.home) == ("alice", 1000, 1000, "/home/alice") + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.CREATE_USER + assert params == {"name": "alice"} + + +@pytest.mark.asyncio +async def test_create_user_raises_on_agent_error(): + send = AsyncMock(return_value={"error": {"message": "User already exists: alice"}}) + sb = _running_sandbox(send) + + with pytest.raises(RuntimeError, match="already exists"): + await sb.create_user("alice") + + +@pytest.mark.asyncio +async def test_sandbox_user_execute_injects_user(): + send = AsyncMock() + # First call: create_user; subsequent: execute. + send.side_effect = [ + {"result": {"uid": 1000, "gid": 1000, "home": "/home/alice"}}, + {"result": {"stdout": "alice", "stderr": "", "exit_code": 0}}, + ] + sb = _running_sandbox(send) + + user = await sb.create_user("alice") + await user.execute("whoami") + + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.EXECUTE + assert params["user"] == "alice" + + +@pytest.mark.asyncio +async def test_delete_user_routes_with_remove_home(): + send = AsyncMock(return_value={"result": {"removed": True}}) + sb = _running_sandbox(send) + + await sb.delete_user("alice", remove_home=False) + + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.DELETE_USER + assert params == {"name": "alice", "remove_home": False}