diff --git a/README.md b/README.md index 66f62b4d0..c48b6b4fa 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,49 @@ smolvm machine run --ssh-agent --net --image alpine -- sh -c "apk add -q openssh smolvm machine exec --name myvm -- git clone git@github.com:org/private-repo.git ``` +**Run guest GUI apps on your host desktop over vsock.** `--waypipe` bridges a guest [waypipe](https://gitlab.freedesktop.org/mstoeckl/waypipe) vsock port to a host Unix socket in the VM data dir — no X11, no SSH server, no TCP port forward. The guest agent runs `waypipe server` as a daemon inside the workload container and exports `WAYLAND_DISPLAY` automatically, and on the host smolvm starts the matching `waypipe client` next to your Wayland compositor for you (Linux hosts). So you just run your GUI app — both ends are wired up. One daemon serves every app (like a normal Wayland display), and it starts on first launch — no per-app `waypipe server` wrapper. + +```bash +smolvm machine create --name gui --net --waypipe --image ubuntu:24.04 +smolvm machine start --name gui + +# Guest: run any GUI app. WAYLAND_DISPLAY is set, the guest daemon is started, +# and the host waypipe client is running against your compositor. +smolvm machine exec --name gui -- weston-terminal +``` + +The host client is started automatically on Linux when `$WAYLAND_DISPLAY` is set and `waypipe` is on the host `PATH`; it lives as long as the VM and is killed with it. If either is missing, smolvm skips it and you can run one by hand: + +```bash +waypipe -s "$(smolvm machine data-dir --name gui)/waypipe.sock" client & +``` + +`--waypipe` takes an optional value selecting which `waypipe` binary the guest daemon runs: + +- `--waypipe` or `--waypipe=host` (default) — share the **host** waypipe binary into the guest, so the guest server and your host client are the exact same binary (no wire-version drift) and the image needs no waypipe installed. Requires the host glibc to be compatible with the guest image's (usually true for a recent image). +- `--waypipe=container` — use the image's **own** `waypipe` (install it yourself, e.g. `apt-get install -y waypipe`). The daemon starts on the first launch after waypipe is present, with no restart. +- `--waypipe=/path/to/waypipe` — share that specific host binary. + +Requires a `--vsock`-capable waypipe (>= 0.9) on the host, and (for `container`) in the guest. `--waypipe` needs an `--image`: the guest daemon runs inside the workload container (the agent's own rootfs is musl and cannot exec a glibc waypipe), which a bare VM does not have — so `--waypipe` without an image is rejected up front. + +One waypipe daemon in the guest serves every app (like the X11 display socket), and it starts lazily — the first launch after you install waypipe brings it up with no VM restart. The bridge uses the same vsock mechanism as `--cuda`, so no networking is required for forwarding itself (`--net` is only needed to install waypipe in the guest). + +If the daemon can't start, smolvm says so rather than failing silently: `machine exec -- cmd` prints the reason on stderr (without disturbing the command's own output), and an interactive `machine exec -it` shell prints it at the top of the session. Either way the message says whether waypipe isn't installed yet (with how to install it — the daemon then comes up on the next command) or is present but failed to start (e.g. a host/guest glibc mismatch in `host` mode — try `--waypipe=container`). + +**Or bridge the raw X11 socket, no waypipe.** `--x11` resolves the host `$DISPLAY` at launch and bridges a guest vsock port straight to that X server's Unix socket. X was designed for network transparency, so guest X clients talk to your host X server directly. The guest agent sets up the display socket and exports `DISPLAY=:10` for you — no `socat`, just run an X client. Needs a running host X server (a native X session, or an Xwayland/`Xephyr` on a Wayland host). + +```bash +smolvm machine create --name xgui --net --x11 --image ubuntu:24.04 +smolvm machine start --name xgui # start with $DISPLAY set + +xhost +local: # allow the bridged connections + +# DISPLAY=:10 is already set in the VM — just run an X client. +smolvm machine exec --name xgui -- sh -c 'apt-get install -y x11-apps && xeyes' +``` + +The X11 bridge is a plain byte pipe (guest connects out to host CID 2, port 7002), so it cannot pass `SCM_RIGHTS` ancillary fds — MIT-SHM and DRI3 fall back to wire-image transport (correct, just slower). For per-window Wayland integration and correct fd/GPU handling, prefer `--waypipe`. + **Declare environments with a Smolfile** — reproducible VM config in a simple TOML file. ```toml @@ -145,6 +188,8 @@ smolvm strengthens the guest/host boundary by giving each workload a separate VM * The `smolvm` CLI and VMM processes run with the permissions of the invoking host user. That user account, the host OS, the hypervisor backend, libkrun, and smolvm are in the trusted computing base. * Host directories passed with `--volume` are intentionally exposed to the guest with the requested access. Do not mount secrets or sensitive paths into an untrusted workload. * `--ssh-agent` does not copy private key material into the guest, but it grants the guest access to the forwarded agent socket and therefore the ability to request signatures while the VM is running. +* `--waypipe` opens a vsock channel from the guest to a host Unix socket that a `waypipe client` reads next to your Wayland compositor. A guest with this enabled can drive that client; only enable it for workloads whose GUI you intend to display. +* `--x11` bridges a guest vsock port straight to your host X server socket, giving the guest a direct connection to that X server. X11 has weak client isolation, so treat a guest with `--x11` as having access to the whole X server (input, other windows, clipboard); only enable it for trusted GUI workloads, and rely on X access control (`xhost`) deliberately. * Networking is disabled by default. Enabling `--net`, port forwarding, or host services expands the workload's reachable surface. * In standalone local use, smolvm's state and control endpoints are scoped to the invoking user's environment. For hostile local co-tenants, add host-level account separation and OS confinement around the VMM process. This section does not describe the separate smolmachines cloud control plane or its tenant-isolation guarantees. * Release archives publish SHA-256 checksums and the installer rejects a mismatch when the checksum file is available. Releases are not currently signed or accompanied by provenance attestations, and the installer permits installation when the checksum file cannot be downloaded. diff --git a/crates/smolvm-agent/src/main.rs b/crates/smolvm-agent/src/main.rs index 0d5a37318..d963f5648 100644 --- a/crates/smolvm-agent/src/main.rs +++ b/crates/smolvm-agent/src/main.rs @@ -97,6 +97,8 @@ mod storage; #[cfg(target_os = "linux")] mod timesync; mod vsock; +mod waypipe; +mod x11; // ============================================================================ // Configuration Constants @@ -420,6 +422,26 @@ fn main() { // `--mount-socket`). No-op when none are configured. publish_socket::start_all(); + // Start the raw X11 socket bridge if enabled by host: the guest binds a + // local display socket (:10) and relays each connection out to the host X + // server over vsock, so guest X clients render on the host X server. Set + // DISPLAY so all child processes (and the agent's own workloads) find it. + if x11::is_enabled() { + info!("X11 socket bridge enabled, starting guest bridge"); + x11::start(); + std::env::set_var("DISPLAY", x11::GUEST_DISPLAY); + } + + // Waypipe Wayland forwarding runs its daemon INSIDE the workload container + // (the agent rootfs is musl and cannot exec a glibc waypipe). The only + // boot-time work is mounting the host-shared waypipe binary (host/path mode) + // so it can be bind-mounted into the container; the daemon itself is started + // once the keep-alive container is up (see `handle_run_detached`). + if waypipe::is_enabled() { + info!("waypipe Wayland forwarding enabled"); + waypipe::mount_shared_binary_at_boot(); + } + // Mount the Rosetta 2 runtime and register the binfmt_misc handler if the // host attached it. Must run after pivot_root (the wrapper lives in the // rootfs) and after /proc is mounted (binfmt_misc registration). @@ -3024,7 +3046,7 @@ fn handle_interactive_run( }; // Spawn the command with crun - let (mut child, pty_master) = match spawn_interactive_command( + let (mut child, pty_master, waypipe_warning) = match spawn_interactive_command( &prepared.rootfs_path, &launch, &mounts, @@ -3046,6 +3068,20 @@ fn handle_interactive_run( // Send Started response send_response(stream, &AgentResponse::Started)?; + // If the waypipe daemon could not start, print the reason to the user's + // terminal before the session begins so forwarding never fails silently. + // Sent as a Stdout frame (the interactive protocol has no separate stderr + // channel); the message already carries the `smolvm:` prefix. CRLF, not LF: + // a raw TTY does no newline translation, so a bare LF would stair-step. + // No-op unless forwarding is enabled and did not come up. (Non-interactive + // exec surfaces the same text on the command stderr.) + if let Some(warning) = waypipe_warning { + let line = format!("\r\n{warning}\r\n"); + let _ = send_response(stream, &AgentResponse::Stdout { + data: line.into_bytes(), + }); + } + // Run the appropriate interactive I/O loop let exit_code = match pty_master { #[cfg(target_os = "linux")] @@ -3213,6 +3249,8 @@ fn write_oci_bundle( storage::add_storage_fallback(&mut spec, mounts, unprivileged); ssh_agent::inject_into_container(&mut spec); + x11::inject_into_container(&mut spec); + waypipe::inject_into_container(&mut spec); rosetta::inject_into_container(&mut spec); cuda::inject_into_container(&mut spec, rootfs_path); spec.write_to(bundle_path) @@ -3430,6 +3468,7 @@ fn handle_run_detached( container_id = %container_id, "detached container started via create+start" ); + send_response( stream, &AgentResponse::Completed { @@ -3556,8 +3595,19 @@ fn spawn_exec_in_container( // An exec joining a running container inherits the same image-resolved env / // workdir as the container's main process. + // + // The container's config.json got SSH_AUTH_SOCK / DISPLAY via the spec + // injection in `write_oci_bundle`, but `crun exec` builds a fresh process env + // from what we pass here, NOT the container's - so those vars have to be + // re-injected onto the exec env or an interactive `-it` join (this path) + // silently loses them. Mirrors the injection `handle_run` does for the + // non-interactive keep-alive exec path (#542). + let mut env: Vec<(String, String)> = launch.env.clone(); + ssh_agent::inject_into_env(&mut env); + x11::inject_into_env(&mut env); + waypipe::inject_into_env(&mut env); let command: &[String] = &launch.command; - let env: &[(String, String)] = &launch.env; + let env: &[(String, String)] = &env; let workdir: Option<&str> = launch.workdir.as_deref(); info!( @@ -3787,6 +3837,10 @@ fn ensure_main_container( Ok(container_id) } +/// Spawn the interactive command, returning the child, its PTY master (when a +/// TTY was requested), and an optional one-line warning to print to the user's +/// terminal before the session starts (currently the waypipe daemon-start +/// outcome, `None` unless forwarding is enabled and did not come up). #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn spawn_interactive_command( @@ -3796,7 +3850,7 @@ fn spawn_interactive_command( tty: bool, persistent_overlay_id: Option<&str>, unprivileged: bool, -) -> Result<(Child, Option), Box> { +) -> Result<(Child, Option, Option), Box> { use std::path::Path; if launch.command.is_empty() { @@ -3818,7 +3872,14 @@ fn spawn_interactive_command( // If a main workload container is running for this overlay, join it. if let Some(cid) = resolve_main_container(persistent_overlay_id) { - return spawn_exec_in_container(&cid, launch, tty); + // Ensure the waypipe daemon is up in this container (idempotent; no-op + // unless forwarding is enabled). Covers the case where the container + // persisted from an earlier exec but the daemon has not been started. + // The outcome's warning (if any) is returned so the caller can print it + // to the user's terminal before the session starts. + let warning = waypipe::start_daemon_in_container(&cid).user_warning(); + let (child, pty) = spawn_exec_in_container(&cid, launch, tty)?; + return Ok((child, pty, warning)); } // On a persistent machine with no main container yet, establish a long-lived @@ -3832,7 +3893,14 @@ fn spawn_interactive_command( // so exec never breaks outright. if let Some(overlay_id) = persistent_overlay_id { match ensure_main_container(rootfs, overlay_id, mounts, unprivileged, launch) { - Ok(cid) => return spawn_exec_in_container(&cid, launch, tty), + Ok(cid) => { + // Start the waypipe daemon in the freshly-established keep-alive + // container (idempotent; no-op unless forwarding is enabled). + // The warning (if any) is returned to the caller to print. + let warning = waypipe::start_daemon_in_container(&cid).user_warning(); + let (child, pty) = spawn_exec_in_container(&cid, launch, tty)?; + return Ok((child, pty, warning)); + } Err(e) => { warn!(error = %e, "keep-alive main container setup failed; running in a fresh container") } @@ -3876,8 +3944,11 @@ fn spawn_interactive_command( ); // The single-container `Run` path keeps cgroups disabled (its VM is the - // limit); per-container cgroups are a pod-only concern. - spawn_crun_run(&bundle_path, &container_id, tty, false) + // limit); per-container cgroups are a pod-only concern. The fresh-container + // path does not start the waypipe daemon (only the persistent keep-alive + // paths do), so there is no warning to carry here. + let (child, pty) = spawn_crun_run(&bundle_path, &container_id, tty, false)?; + Ok((child, pty, None)) } /// Launch a container with `crun run` and hand back the child plus the PTY @@ -3990,7 +4061,7 @@ fn spawn_interactive_command( _tty: bool, _persistent_overlay_id: Option<&str>, unprivileged: bool, -) -> Result<(Child, Option<()>), Box> { +) -> Result<(Child, Option<()>, Option), Box> { use std::path::Path; let command: &[String] = &launch.command; @@ -4031,6 +4102,8 @@ fn spawn_interactive_command( // Forward SSH agent into the container if enabled at boot. ssh_agent::inject_into_container(&mut spec); + x11::inject_into_container(&mut spec); + waypipe::inject_into_container(&mut spec); rosetta::inject_into_container(&mut spec); cuda::inject_into_container(&mut spec, rootfs_path); @@ -4044,7 +4117,7 @@ fn spawn_interactive_command( .capture_output() .spawn()?; - Ok((child, None)) + Ok((child, None, None)) } /// Run the interactive I/O loop using poll() for efficient I/O multiplexing. @@ -5042,6 +5115,13 @@ fn run_in_keepalive_container( } }; + // Ensure the waypipe daemon is up in the keep-alive container before the + // workload runs (idempotent; no-op unless forwarding is enabled). If it + // could not come up - waypipe not installed yet, or present but broken - the + // reason is surfaced on this exec's stderr below so forwarding never fails + // silently. Only a running daemon is silent. + let waypipe_warning = waypipe::start_daemon_in_container(&cid).user_warning(); + // The workload runs via `crun exec --user`, which requires a NUMERIC uid[:gid] // — a username (the image's `config.User`, e.g. `nobody`/`node`, or the // request user) is rejected with "invalid USERSPEC specified". Resolve it @@ -5087,11 +5167,23 @@ fn run_in_keepalive_container( }, )?; + // Prepend the waypipe warning (if any) to the command's stderr so the user + // sees why forwarding did not come up, without corrupting stdout. + let prepend_waypipe_warning = |stderr: Vec| -> Vec { + if let Some(warning) = &waypipe_warning { + let mut prefixed = format!("{warning}\n").into_bytes(); + prefixed.extend_from_slice(&stderr); + prefixed + } else { + stderr + } + }; + Ok(match result { crate::process::WaitResult::Completed { exit_code, output } => AgentResponse::Completed { exit_code, stdout: output.stdout, - stderr: output.stderr, + stderr: prepend_waypipe_warning(output.stderr), }, crate::process::WaitResult::TimedOut { output, timeout_ms } => { let mut stderr = output.stderr; @@ -5101,7 +5193,7 @@ fn run_in_keepalive_container( AgentResponse::Completed { exit_code: crate::process::TIMEOUT_EXIT_CODE, stdout: output.stdout, - stderr, + stderr: prepend_waypipe_warning(stderr), } } crate::process::WaitResult::ClientDisconnected { output } => { @@ -5110,7 +5202,7 @@ fn run_in_keepalive_container( AgentResponse::Completed { exit_code: 137, stdout: output.stdout, - stderr, + stderr: prepend_waypipe_warning(stderr), } } }) @@ -5137,6 +5229,8 @@ fn handle_run( // is off; harmless on the fresh-container path. let mut env = env.to_vec(); ssh_agent::inject_into_env(&mut env); + x11::inject_into_env(&mut env); + waypipe::inject_into_env(&mut env); let env = &env[..]; // Honor the image's default USER when the request doesn't pin one, so every diff --git a/crates/smolvm-agent/src/pod.rs b/crates/smolvm-agent/src/pod.rs index 4abc7c3c8..d1a7f6224 100644 --- a/crates/smolvm-agent/src/pod.rs +++ b/crates/smolvm-agent/src/pod.rs @@ -963,6 +963,8 @@ fn write_pod_bundle( // Same injections as Run's bundle build (write_oci_bundle). crate::ssh_agent::inject_into_container(&mut spec); + crate::x11::inject_into_container(&mut spec); + crate::waypipe::inject_into_container(&mut spec); crate::rosetta::inject_into_container(&mut spec); crate::cuda::inject_into_container(&mut spec, &pod.rootfs); diff --git a/crates/smolvm-agent/src/storage.rs b/crates/smolvm-agent/src/storage.rs index 05970921e..fb083d027 100644 --- a/crates/smolvm-agent/src/storage.rs +++ b/crates/smolvm-agent/src/storage.rs @@ -2796,6 +2796,8 @@ pub fn run_command( // Forward SSH agent into the container if enabled at boot. crate::ssh_agent::inject_into_container(&mut spec); + crate::x11::inject_into_container(&mut spec); + crate::waypipe::inject_into_container(&mut spec); crate::cuda::inject_into_container(&mut spec, Path::new(&prepared.rootfs_path)); // Write config.json to bundle @@ -2887,6 +2889,8 @@ pub fn spawn_in_overlay( add_storage_fallback(&mut spec, mounts, unprivileged); crate::ssh_agent::inject_into_container(&mut spec); + crate::x11::inject_into_container(&mut spec); + crate::waypipe::inject_into_container(&mut spec); crate::cuda::inject_into_container(&mut spec, Path::new(&prepared.rootfs_path)); spec.add_gpu_devices_if_available(); diff --git a/crates/smolvm-agent/src/waypipe.rs b/crates/smolvm-agent/src/waypipe.rs new file mode 100644 index 000000000..da2a6ff20 --- /dev/null +++ b/crates/smolvm-agent/src/waypipe.rs @@ -0,0 +1,574 @@ +//! Guest-side waypipe Wayland forwarding. +//! +//! The Wayland analog of the X11 bridge, but with a different mechanism. +//! Wayland cannot be byte-relayed the way X11 can: every frame, keymap, and +//! clipboard transfer rides as an `SCM_RIGHTS` file descriptor (shm/dmabuf) +//! over the socket, and a raw relay drops those fds. So instead of the agent +//! relaying bytes itself (as [`crate::x11`] does), we run +//! [waypipe](https://gitlab.freedesktop.org/mstoeckl/waypipe), which terminates +//! the protocol on each side and re-materializes the buffers across the +//! transport. +//! +//! libkrun already bridges the guest's outbound [`ports::WAYPIPE`] vsock port to +//! a host Unix socket where the user runs a listening `waypipe client` next to +//! the host compositor. The guest's job is to run `waypipe server` in daemon +//! mode: with `--display` it creates a Wayland display socket and forwards +//! *every* client that connects to it (one daemon for all apps, like the X11 +//! display socket), connecting out over vsock to the host client. The container +//! workload gets `WAYLAND_DISPLAY` set automatically, so guest GUI apps just +//! work. +//! +//! ## Why the daemon runs *inside the container* +//! +//! The agent's own rootfs is musl Alpine; a glibc `waypipe` (the host binary, or +//! a Debian/Ubuntu image's) cannot be exec'd there (missing `/lib64` loader). So +//! the daemon runs inside the workload container, which has a matching libc. +//! [`start_daemon_in_container`] fires one detached `crun exec` after the +//! keep-alive container is up; the daemon persists across subsequent execs and +//! its display socket lives in the container's own `/tmp/waypipe`. +//! +//! ## Binary source +//! +//! [`guest_env::WAYPIPE_BIN`] selects which binary the daemon runs. When set to +//! an absolute path (the host binary the launcher shared via +//! [`smolvm_protocol::WAYPIPE_TAG`], bind-mounted into the container by +//! [`inject_into_container`]), the daemon execs that; otherwise it uses +//! `waypipe` from the container's `PATH` (the image's own install). + +use smolvm_protocol::{guest_env, ports}; + +/// `WAYLAND_DISPLAY` value exported into the workload env. Relative, so it +/// resolves under `XDG_RUNTIME_DIR` per the Wayland convention. +pub const GUEST_WAYLAND_DISPLAY: &str = "wayland-waypipe"; + +/// Directory used as `XDG_RUNTIME_DIR` for the daemon and workloads, so +/// `--display wayland-waypipe` lands at a deterministic path. Lives inside the +/// container's own filesystem (created by the daemon exec). +pub const GUEST_WAYLAND_DIR: &str = "/tmp/waypipe"; + +/// Whether waypipe Wayland forwarding is enabled for this launch. +pub fn is_enabled() -> bool { + std::env::var(guest_env::WAYPIPE).as_deref() == Ok(guest_env::VALUE_ON) +} + +/// Mount the shared host `waypipe` binary at boot, when forwarding is enabled in +/// shared-host-binary mode (an absolute [`guest_env::WAYPIPE_BIN`]). The +/// launcher attached it as virtiofs tag [`smolvm_protocol::WAYPIPE_TAG`]; this +/// mounts it in the agent namespace so [`inject_into_container`] can bind-mount +/// it into the workload container. No-op in container-PATH mode (nothing shared) +/// or when disabled. Mirrors [`crate::rosetta`]. Best-effort: logs on failure. +#[cfg(target_os = "linux")] +pub fn mount_shared_binary_at_boot() { + if !is_enabled() { + return; + } + let Some(dir) = shared_binary_dir() else { + return; // container-PATH mode: nothing to mount. + }; + if let Err(e) = mount_tag(&dir) { + tracing::warn!(error = %e, dir = %dir, "failed to mount shared waypipe binary; forwarding may fall back to container PATH"); + } +} + +#[cfg(not(target_os = "linux"))] +pub fn mount_shared_binary_at_boot() {} + +/// Mount virtiofs tag [`smolvm_protocol::WAYPIPE_TAG`] at `dir`. Idempotent: if +/// the binary is already visible there, the mount is left as-is. +#[cfg(target_os = "linux")] +fn mount_tag(dir: &str) -> std::io::Result<()> { + use std::ffi::CString; + + std::fs::create_dir_all(dir)?; + if std::path::Path::new(dir).join("waypipe").exists() { + return Ok(()); + } + + let src = CString::new(smolvm_protocol::WAYPIPE_TAG).expect("tag has no null byte"); + let dst = CString::new(dir).expect("path has no null byte"); + let fstype = CString::new("virtiofs").expect("literal has no null byte"); + // SAFETY: all args are valid null-terminated C strings; virtiofs takes no + // mount data (matches rosetta::mount_runtime). + let rc = unsafe { + libc::mount( + src.as_ptr(), + dst.as_ptr(), + fstype.as_ptr(), + 0, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// The `waypipe` binary the daemon should run inside the container. +/// +/// A non-empty [`guest_env::WAYPIPE_BIN`] is the absolute path of the shared +/// host binary (bind-mounted in); otherwise the container's own `waypipe` on +/// `PATH` is used. +fn daemon_binary() -> String { + match std::env::var(guest_env::WAYPIPE_BIN) { + Ok(p) if !p.is_empty() => p, + _ => "waypipe".to_string(), + } +} + +/// The in-container path of the shared host binary, if one was shared (i.e. +/// [`guest_env::WAYPIPE_BIN`] is a non-empty path). Used to bind-mount it into +/// the container. `None` in container-PATH mode. +fn shared_binary_dir() -> Option { + match std::env::var(guest_env::WAYPIPE_BIN) { + Ok(p) if !p.is_empty() => std::path::Path::new(&p) + .parent() + .map(|d| d.to_string_lossy().into_owned()), + _ => None, + } +} + +/// Outcome of a [`start_daemon_in_container`] attempt, so callers can decide +/// whether to surface something to the user. The forwarding daemon is lazy and +/// self-healing, so most outcomes are silent; only a genuine failure with +/// waypipe actually present warrants a user-visible warning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaemonStartOutcome { + /// Forwarding is off, or the daemon is already running (socket present), or + /// it started cleanly. Nothing to report. + Ok, + /// The daemon exec failed and `waypipe` is NOT present in the container yet. + /// Expected while the user has not installed it; the next exec retries. The + /// contained string is the binary name/path that was looked for. + WaypipeMissing(String), + /// `waypipe` IS present in the container but the daemon still failed to + /// start (e.g. a glibc mismatch in host-binary mode, or it exited before + /// creating its display socket). Retrying will not help; surface this. The + /// string is a short human-readable reason. + Failed(String), +} + +/// Start the waypipe forwarding daemon inside the running keep-alive container. +/// +/// Fires one detached `crun exec` running `waypipe server` in daemon mode. The +/// daemon persists after this exec returns (it is re-parented to the container's +/// init), so subsequent workload execs find its display socket. Best-effort: it +/// never fails the launch, but returns a [`DaemonStartOutcome`] so the caller +/// can surface a genuine failure to the user. No-op when forwarding is disabled. +#[cfg(target_os = "linux")] +pub fn start_daemon_in_container(container_id: &str) -> DaemonStartOutcome { + if !is_enabled() { + return DaemonStartOutcome::Ok; + } + + // Idempotent: if the daemon's display socket already exists in the + // container, a daemon is already running - don't spawn a duplicate. Every + // exec calls this, so the check must be cheap (one `test -S`). + if daemon_socket_present(container_id) { + return DaemonStartOutcome::Ok; + } + + let bin = daemon_binary(); + // Set up the runtime dir, drop any stale socket, then run the daemon. + // `setsid` detaches it from the exec's session so it outlives this call; + // `nohup`-style stdio redirection keeps it from holding the exec's fds. + // `--display` makes waypipe create a Wayland display socket (under + // XDG_RUNTIME_DIR) and forward every client that connects, rather than + // wrapping a single child app. `-s 2:` connects out over vsock to + // host CID 2, where libkrun bridges to the host `waypipe client`. See the + // man page's --display example. + let script = format!( + "mkdir -p {dir}; rm -f {dir}/{disp}; \ + XDG_RUNTIME_DIR={dir} setsid {bin} --vsock -s 2:{port} --display {disp} \ + server -- sleep infinity /dev/null 2>&1 &", + dir = GUEST_WAYLAND_DIR, + disp = GUEST_WAYLAND_DISPLAY, + bin = bin, + port = ports::WAYPIPE, + ); + + let command = [ + "/bin/sh".to_string(), + "-c".to_string(), + script, + ]; + let env: [(String, String); 0] = []; + + // `crun start` returns before the container is necessarily ready for exec + // (a fresh `crun exec` races it and fails with status 255). Wait briefly for + // the container to reach the running state before firing the daemon. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !crate::is_container_running(container_id) { + if std::time::Instant::now() >= deadline { + tracing::warn!( + container_id = %container_id, + "container not running in time; waypipe daemon not started" + ); + return DaemonStartOutcome::Failed( + "container did not reach the running state in time".to_string(), + ); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + + // The `&` backgrounds the daemon inside the container, so this `sh -c` exits + // promptly with status 0; a non-zero status means the exec itself could not + // run (e.g. `waypipe` not yet installed in container-PATH mode), which is a + // best-effort miss - the next exec retries via the socket-presence check. + let spawn_result = crate::crun::CrunCommand::exec(container_id, &env, &command, None, false) + .stdin_null() + .discard_output() + .spawn() + .and_then(|mut c| c.wait()); + + // The daemon is backgrounded with `&` inside `sh -c`, so the exec returns + // status 0 as soon as the shell forks it - regardless of whether `waypipe` + // was found or started. So a status 0 tells us little; the real signal is + // whether the display socket appears. Classify by socket + binary presence: + // - socket appears -> Ok (daemon up) + // - no socket, no binary -> WaypipeMissing (expected; silent retry) + // - no socket, binary present -> Failed (present but broken; surface it) + match spawn_result { + Ok(status) if status.success() => { + if wait_for_socket(container_id) { + tracing::info!( + display = GUEST_WAYLAND_DISPLAY, + binary = %bin, + vsock_port = ports::WAYPIPE, + "started waypipe daemon in container" + ); + DaemonStartOutcome::Ok + } else if binary_present(container_id, &bin) { + tracing::warn!( + binary = %bin, + "waypipe is present but its daemon exited without creating a display socket" + ); + DaemonStartOutcome::Failed(format!( + "the waypipe daemon ({bin}) is present but exited without creating a \ + display socket (in host-binary mode this is often a glibc mismatch \ + between host and guest image; try --waypipe=container with waypipe \ + installed in the image)" + )) + } else { + tracing::info!( + binary = %bin, + "waypipe not present in container yet; will retry on next exec" + ); + DaemonStartOutcome::WaypipeMissing(bin) + } + } + Ok(status) => { + // The exec itself could not run at all (e.g. crun error). Rare on + // this path; treat as a real failure worth surfacing. + tracing::warn!( + binary = %bin, + status = %status, + "waypipe daemon exec exited non-zero" + ); + DaemonStartOutcome::Failed(format!( + "the daemon exec exited {status} (could not run waypipe in the container)" + )) + } + Err(e) => { + tracing::warn!(error = %e, "failed to start waypipe daemon in container; Wayland forwarding unavailable"); + DaemonStartOutcome::Failed(format!("could not exec into the container: {e}")) + } + } +} + +/// Poll briefly for the daemon's display socket to appear after a successful +/// daemon exec, so we can distinguish a daemon that stayed up from one that +/// exited immediately. Short deadline: the socket is created as one of the +/// daemon's first actions. +#[cfg(target_os = "linux")] +fn wait_for_socket(container_id: &str) -> bool { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if daemon_socket_present(container_id) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +/// Whether `bin` resolves to an executable in the container: an absolute path +/// (host-binary mode) is tested directly; a bare name (`waypipe`, container-PATH +/// mode) is resolved via `command -v`. +#[cfg(target_os = "linux")] +fn binary_present(container_id: &str, bin: &str) -> bool { + let probe = if bin.starts_with('/') { + format!("test -x {bin}") + } else { + format!("command -v {bin} >/dev/null 2>&1") + }; + let command = [ + "/bin/sh".to_string(), + "-c".to_string(), + probe, + ]; + let env: [(String, String); 0] = []; + crate::crun::CrunCommand::exec(container_id, &env, &command, None, false) + .stdin_null() + .discard_output() + .spawn() + .and_then(|mut c| c.wait()) + .map(|status| status.success()) + .unwrap_or(false) +} + +/// Whether the daemon's display socket already exists in the container (i.e. a +/// daemon is already running there). One cheap `crun exec test -S`. +#[cfg(target_os = "linux")] +fn daemon_socket_present(container_id: &str) -> bool { + let sock = format!("{}/{}", GUEST_WAYLAND_DIR, GUEST_WAYLAND_DISPLAY); + let command = [ + "test".to_string(), + "-S".to_string(), + sock, + ]; + let env: [(String, String); 0] = []; + crate::crun::CrunCommand::exec(container_id, &env, &command, None, false) + .stdin_null() + .discard_output() + .spawn() + .and_then(|mut c| c.wait()) + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +pub fn start_daemon_in_container(_container_id: &str) -> DaemonStartOutcome { + DaemonStartOutcome::Ok +} + +impl DaemonStartOutcome { + /// A one-line, user-facing warning for this outcome, or `None` when nothing + /// should be surfaced. Only `Ok` (daemon up) is silent. `WaypipeMissing` + /// warns too: forwarding retries lazily, but until waypipe is installed GUI + /// apps will not appear on the host, so the user needs to know why. `Failed` + /// reports the concrete reason. + pub fn user_warning(&self) -> Option { + match self { + DaemonStartOutcome::Ok => None, + DaemonStartOutcome::WaypipeMissing(bin) => Some(format!( + "smolvm: waypipe forwarding is not active yet: {bin} is not installed in \ + the container, so guest GUI apps will not appear on the host. Install \ + waypipe in the guest (e.g. `apt-get install -y waypipe`); it starts \ + automatically on the next command with no restart." + )), + DaemonStartOutcome::Failed(reason) => { + Some(format!("smolvm: waypipe forwarding is not available: {reason}")) + } + } + } +} + +/// Inject waypipe Wayland forwarding into an OCI container spec. +/// +/// Sets `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` so guest GUI apps find the daemon's +/// display socket, and - in shared-host-binary mode - bind-mounts the shared +/// binary's directory into the container so the daemon exec can run it. No-op +/// when forwarding is disabled. Mirrors [`crate::x11::inject_into_container`]. +pub fn inject_into_container(spec: &mut crate::oci::OciSpec) { + inject_into_container_if(spec, is_enabled(), shared_binary_dir()); +} + +/// Testable core of [`inject_into_container`]. +fn inject_into_container_if( + spec: &mut crate::oci::OciSpec, + enabled: bool, + shared_dir: Option, +) { + if !enabled { + return; + } + // Share the host binary's mount into the container (rw not needed; the + // binary is executed, not written). Only in shared-host-binary mode. + if let Some(dir) = shared_dir { + spec.add_bind_mount(&dir, &dir, true); + } + spec.add_env("XDG_RUNTIME_DIR", GUEST_WAYLAND_DIR); + spec.add_env("WAYLAND_DISPLAY", GUEST_WAYLAND_DISPLAY); +} + +/// Add the Wayland env to a command's env list when forwarding is enabled. +/// +/// The keep-alive container's `crun exec` path (#542) and the interactive `-it` +/// join build a fresh process env rather than inheriting the container's, so the +/// spec injection never reaches them. This wires `WAYLAND_DISPLAY` / +/// `XDG_RUNTIME_DIR` into that exec/run env. No-op when disabled; never +/// overrides a user-supplied value. Mirrors [`crate::x11::inject_into_env`]. +pub fn inject_into_env(env: &mut Vec<(String, String)>) { + inject_into_env_if(env, is_enabled()); +} + +/// Testable core of [`inject_into_env`]. +fn inject_into_env_if(env: &mut Vec<(String, String)>, enabled: bool) { + if !enabled { + return; + } + if !env.iter().any(|(k, _)| k == "WAYLAND_DISPLAY") { + env.push(( + "WAYLAND_DISPLAY".to_string(), + GUEST_WAYLAND_DISPLAY.to_string(), + )); + } + if !env.iter().any(|(k, _)| k == "XDG_RUNTIME_DIR") { + env.push(("XDG_RUNTIME_DIR".to_string(), GUEST_WAYLAND_DIR.to_string())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oci::{OciSpec, ProcessIdentity}; + + #[test] + fn inject_into_env_adds_wayland_only_when_enabled() { + // Enabled -> injected. + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + inject_into_env_if(&mut env, true); + assert!( + env.iter() + .any(|(k, v)| k == "WAYLAND_DISPLAY" && v == GUEST_WAYLAND_DISPLAY), + "WAYLAND_DISPLAY must be injected when forwarding is enabled" + ); + assert!( + env.iter() + .any(|(k, v)| k == "XDG_RUNTIME_DIR" && v == GUEST_WAYLAND_DIR), + "XDG_RUNTIME_DIR must be injected when forwarding is enabled" + ); + + // Disabled -> no-op. + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + inject_into_env_if(&mut env, false); + assert!(!env.iter().any(|(k, _)| k == "WAYLAND_DISPLAY")); + assert!(!env.iter().any(|(k, _)| k == "XDG_RUNTIME_DIR")); + } + + #[test] + fn inject_into_env_never_overrides_user_value() { + let mut env = vec![ + ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()), + ("XDG_RUNTIME_DIR".to_string(), "/run/user/1000".to_string()), + ]; + inject_into_env_if(&mut env, true); + assert_eq!( + env.iter().filter(|(k, _)| k == "WAYLAND_DISPLAY").count(), + 1 + ); + assert_eq!( + env.iter() + .find(|(k, _)| k == "WAYLAND_DISPLAY") + .map(|(_, v)| v.as_str()), + Some("wayland-0") + ); + assert_eq!( + env.iter() + .find(|(k, _)| k == "XDG_RUNTIME_DIR") + .map(|(_, v)| v.as_str()), + Some("/run/user/1000") + ); + } + + #[test] + fn inject_is_noop_when_disabled() { + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + let mounts_before = spec.mounts.len(); + let envs_before = spec.process.env.len(); + + inject_into_container_if(&mut spec, false, None); + + assert_eq!(spec.mounts.len(), mounts_before); + assert_eq!(spec.process.env.len(), envs_before); + assert!(!spec + .process + .env + .iter() + .any(|e| e.starts_with("WAYLAND_DISPLAY="))); + } + + #[test] + fn inject_sets_env_and_no_mount_in_container_mode() { + // Container-PATH mode (no shared binary): env set, no bind mount added. + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + let mounts_before = spec.mounts.len(); + + inject_into_container_if(&mut spec, true, None); + + assert!(spec + .process + .env + .iter() + .any(|e| e == &format!("WAYLAND_DISPLAY={}", GUEST_WAYLAND_DISPLAY))); + assert!(spec + .process + .env + .iter() + .any(|e| e == &format!("XDG_RUNTIME_DIR={}", GUEST_WAYLAND_DIR))); + assert_eq!( + spec.mounts.len(), + mounts_before, + "container-PATH mode must not add a bind mount" + ); + } + + #[test] + fn user_warning_silent_only_when_daemon_up() { + // Only Ok (daemon up) is silent. + assert_eq!(DaemonStartOutcome::Ok.user_warning(), None); + + // WaypipeMissing warns: names the binary and how to fix it. + let missing = DaemonStartOutcome::WaypipeMissing("waypipe".to_string()) + .user_warning() + .expect("WaypipeMissing must warn - GUI apps will not forward"); + assert!(missing.contains("waypipe")); + assert!(missing.contains("install")); + + // Failed surfaces the concrete reason. + let msg = DaemonStartOutcome::Failed("boom".to_string()) + .user_warning() + .expect("Failed must produce a warning"); + assert!(msg.contains("boom")); + assert!(msg.contains("waypipe")); + } + + #[test] + fn inject_binds_shared_binary_dir_in_host_mode() { + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + + inject_into_container_if(&mut spec, true, Some("/mnt/waypipe".to_string())); + + let mount = spec + .mounts + .iter() + .find(|m| m.destination == "/mnt/waypipe") + .expect("bind mount for shared waypipe binary not found"); + assert_eq!(mount.source, "/mnt/waypipe"); + assert_eq!(mount.mount_type.as_deref(), Some("bind")); + } +} diff --git a/crates/smolvm-agent/src/x11.rs b/crates/smolvm-agent/src/x11.rs new file mode 100644 index 000000000..1eb28625a --- /dev/null +++ b/crates/smolvm-agent/src/x11.rs @@ -0,0 +1,339 @@ +//! Guest-side raw X11 socket bridge. +//! +//! The X11 analog of the SSH agent bridge (same outbound direction): instead of +//! the user wiring `socat` by hand, the guest agent creates a local X11 display +//! socket and, for each connection an X client opens, relays bytes out to the +//! host X server over the [`ports::X11`] vsock port. libkrun bridges that port +//! straight to the host X server's Unix socket (resolved from the host +//! `$DISPLAY`), so guest X clients render on the host X server transparently. +//! +//! Enabled by [`guest_env::X11`]. The agent binds the display socket for +//! [`GUEST_DISPLAY`] (`/tmp/.X11-unix/X10`) and exports `DISPLAY=:10` into the +//! workload env, so an X client just works with no manual setup. +//! +//! Note: a plain byte relay cannot carry `SCM_RIGHTS` ancillary fds, so MIT-SHM +//! and DRI3 fall back to wire-image transport (correct, just slower). For +//! per-window Wayland integration and correct fd/GPU handling, prefer waypipe. + +use smolvm_protocol::{guest_env, ports}; +use std::io; +use std::os::unix::net::UnixListener; +use std::thread; + +/// `DISPLAY` value exported into the workload env when the bridge is enabled. +/// Hardcoded for now; the display socket lives at [`GUEST_X11_SOCK`]. +pub const GUEST_DISPLAY: &str = ":10"; + +/// In-guest path of the X11 display socket the bridge listens on. X clients +/// resolve `DISPLAY=:10` to this path. +pub const GUEST_X11_SOCK: &str = "/tmp/.X11-unix/X10"; + +/// Whether the X11 socket bridge is enabled for this launch. +pub fn is_enabled() -> bool { + std::env::var(guest_env::X11).as_deref() == Ok(guest_env::VALUE_ON) +} + +/// Start the guest-side X11 socket bridge in a background thread. +/// +/// Binds a Unix socket at [`GUEST_X11_SOCK`] and, for each incoming connection, +/// opens a vsock connection to the host-side bridge on [`ports::X11`] and relays +/// bytes bidirectionally. +pub fn start() { + thread::Builder::new() + .name("x11-bridge-guest".into()) + .spawn(|| { + if let Err(e) = run_bridge() { + tracing::warn!(error = %e, "guest X11 socket bridge stopped"); + } + }) + .ok(); +} + +/// Inject `DISPLAY` into an OCI container spec when the bridge is enabled. +/// +/// The container lives in its own mount namespace, so the display socket at +/// [`GUEST_X11_SOCK`] must be bind-mounted in, and it gets env from the image + +/// request (not the agent's own env), so `DISPLAY` has to be set explicitly. +/// No-op when the bridge is disabled. Mirrors [`crate::ssh_agent::inject_into_container`]. +pub fn inject_into_container(spec: &mut crate::oci::OciSpec) { + inject_into_container_if(spec, is_enabled()); +} + +/// Testable core of [`inject_into_container`]. Bind-mounts the display socket +/// and sets `DISPLAY` when `enabled`; no-op otherwise. Split out so tests can +/// exercise the injection without mutating the process-wide `SMOLVM_X11` env. +fn inject_into_container_if(spec: &mut crate::oci::OciSpec, enabled: bool) { + if !enabled { + return; + } + // Bind-mount the display socket; rw because the X11 protocol is bidirectional. + spec.add_bind_mount(GUEST_X11_SOCK, GUEST_X11_SOCK, false); + spec.add_env("DISPLAY", GUEST_DISPLAY); +} + +/// Add `DISPLAY` to a command's env list when the bridge is enabled. +/// +/// The keep-alive container's `crun exec` path (#542) builds a fresh process env +/// rather than inheriting the container's, so the spec injection never reaches +/// it. This wires `DISPLAY` into that exec/run env. No-op when disabled; never +/// overrides an existing value (e.g. a user-supplied `-e DISPLAY`). Mirrors +/// [`crate::ssh_agent::inject_into_env`]. +pub fn inject_into_env(env: &mut Vec<(String, String)>) { + inject_into_env_if(env, is_enabled()); +} + +/// Testable core of [`inject_into_env`]. +fn inject_into_env_if(env: &mut Vec<(String, String)>, enabled: bool) { + if enabled && !env.iter().any(|(k, _)| k == "DISPLAY") { + env.push(("DISPLAY".to_string(), GUEST_DISPLAY.to_string())); + } +} + +fn run_bridge() -> io::Result<()> { + let sock_path = std::path::Path::new(GUEST_X11_SOCK); + + // Clean up any stale socket (e.g. a guest-native X server slot). + let _ = std::fs::remove_file(sock_path); + + // Ensure the X11 socket directory exists (/tmp/.X11-unix). + if let Some(parent) = sock_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let listener = UnixListener::bind(sock_path)?; + + // Make the socket accessible to all users in the VM (workloads may run as + // non-root), matching the mode the manual `socat` recipe used. + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o777))?; + } + + tracing::info!( + path = GUEST_X11_SOCK, + display = GUEST_DISPLAY, + vsock_port = ports::X11, + "guest X11 socket bridge listening" + ); + + for stream in listener.incoming() { + match stream { + Ok(local_conn) => { + thread::Builder::new() + .name("x11-bridge-fwd".into()) + .spawn(move || { + if let Err(e) = relay_to_host(local_conn) { + tracing::debug!(error = %e, "X11 bridge relay ended"); + } + }) + .ok(); + } + Err(e) => { + tracing::debug!(error = %e, "guest X11 accept error"); + if e.kind() == io::ErrorKind::InvalidInput { + break; + } + } + } + } + + Ok(()) +} + +/// Relay one X client connection (arriving on the local display socket) to the +/// host X server over vsock, forwarding bytes in both directions with +/// independent half-close. +/// +/// X11 connections are long-lived and often idle (a window sits open waiting for +/// events), so - like the Docker relay and unlike the short-lived SSH agent +/// relay - this blocks on `poll` indefinitely and only exits once both +/// directions have closed. +#[cfg(target_os = "linux")] +fn relay_to_host(local: std::os::unix::net::UnixStream) -> io::Result<()> { + use std::os::unix::io::AsRawFd; + + let mut host = crate::vsock::connect(ports::X11)?; + let mut local = local; + + let local_fd = local.as_raw_fd(); + let host_fd = host.as_raw_fd(); + + let mut buf = [0u8; 65536]; + + // Track each direction independently so a half-close is mirrored, not + // treated as a full teardown. + let mut local_read_open = true; + let mut host_read_open = true; + + while local_read_open || host_read_open { + let mut poll_fds = [ + libc::pollfd { + // A negative fd is ignored by poll(), so a closed read side + // stops waking the loop while the other direction drains. + fd: if local_read_open { local_fd } else { -1 }, + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: if host_read_open { host_fd } else { -1 }, + events: libc::POLLIN, + revents: 0, + }, + ]; + + // Block until an open side is readable/closed (no idle timeout). + let ret = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, -1) }; + if ret < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(err); + } + + // local -> host + if local_read_open + && poll_fds[0].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 + { + let n = io::Read::read(&mut local, &mut buf)?; + if n == 0 { + local_read_open = false; + // SAFETY: host_fd is the valid, open fd owned by `host`. + unsafe { libc::shutdown(host_fd, libc::SHUT_WR) }; + } else { + io::Write::write_all(&mut host, &buf[..n])?; + } + } + + // host -> local + if host_read_open + && poll_fds[1].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 + { + let n = io::Read::read(&mut host, &mut buf)?; + if n == 0 { + host_read_open = false; + let _ = local.shutdown(std::net::Shutdown::Write); + } else { + io::Write::write_all(&mut local, &buf[..n])?; + } + } + } + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn relay_to_host(_local: std::os::unix::net::UnixStream) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "X11 socket bridge only supported on Linux guests", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oci::{OciSpec, ProcessIdentity}; + + #[test] + fn inject_into_env_adds_display_only_when_enabled() { + // Enabled -> injected. + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + inject_into_env_if(&mut env, true); + assert!( + env.iter().any(|(k, v)| k == "DISPLAY" && v == GUEST_DISPLAY), + "DISPLAY must be injected into the exec/run env when the bridge is enabled" + ); + + // Disabled -> no-op. + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + inject_into_env_if(&mut env, false); + assert!(!env.iter().any(|(k, _)| k == "DISPLAY")); + + // Never overrides a user-supplied value. + let mut env = vec![("DISPLAY".to_string(), ":99".to_string())]; + inject_into_env_if(&mut env, true); + assert_eq!(env.iter().filter(|(k, _)| k == "DISPLAY").count(), 1); + assert_eq!(env[0].1, ":99"); + } + + #[test] + fn inject_is_noop_when_disabled() { + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + let mounts_before = spec.mounts.len(); + let envs_before = spec.process.env.len(); + + inject_into_container_if(&mut spec, false); + + assert_eq!(spec.mounts.len(), mounts_before); + assert_eq!(spec.process.env.len(), envs_before); + assert!(!spec.process.env.iter().any(|e| e.starts_with("DISPLAY="))); + } + + #[test] + fn inject_adds_env_and_mount_when_enabled() { + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + + inject_into_container_if(&mut spec, true); + + // Env must point at the guest display. + assert!(spec + .process + .env + .iter() + .any(|e| e == &format!("DISPLAY={}", GUEST_DISPLAY))); + + // Mount must bind the display socket at the same path inside the container. + let mount = spec + .mounts + .iter() + .find(|m| m.destination == GUEST_X11_SOCK) + .expect("bind mount for X11 display socket not found"); + assert_eq!(mount.source, GUEST_X11_SOCK); + assert_eq!(mount.mount_type.as_deref(), Some("bind")); + // rw: the X11 protocol is bidirectional. + assert!(!mount.options.iter().any(|o| o == "ro")); + assert!(mount.options.iter().any(|o| o == "bind")); + } + + #[test] + fn inject_replaces_existing_display() { + // An image whose config already exports a stale DISPLAY: keep exactly + // one entry (duplicate keys leave the effective value shell-dependent). + let mut spec = OciSpec::new( + &["true".to_string()], + &[], + "/", + false, + &ProcessIdentity::root(), + false, + ); + spec.process.env.push("DISPLAY=:99".to_string()); + + inject_into_container_if(&mut spec, true); + + let matches: Vec<_> = spec + .process + .env + .iter() + .filter(|e| e.starts_with("DISPLAY=")) + .collect(); + assert_eq!(matches.len(), 1, "duplicate DISPLAY entries"); + assert_eq!(matches[0], &format!("DISPLAY={}", GUEST_DISPLAY)); + } +} diff --git a/crates/smolvm-cuda/src/client.rs b/crates/smolvm-cuda/src/client.rs index fd769cbb4..f11056597 100644 --- a/crates/smolvm-cuda/src/client.rs +++ b/crates/smolvm-cuda/src/client.rs @@ -751,7 +751,7 @@ impl Client { // also populates the server cache. if image.len() >= 64 { let mut blob = Vec::with_capacity(32); - blob.extend_from_slice(&crate::host::fnv64(image).to_le_bytes()); + blob.extend_from_slice(&crate::fnv64(image).to_le_bytes()); blob.extend_from_slice(&(image.len() as u64).to_le_bytes()); blob.extend_from_slice(&image[..8]); blob.extend_from_slice(&image[image.len() - 8..]); diff --git a/crates/smolvm-cuda/src/host.rs b/crates/smolvm-cuda/src/host.rs index c9ab4bf81..87721e4ee 100644 --- a/crates/smolvm-cuda/src/host.rs +++ b/crates/smolvm-cuda/src/host.rs @@ -809,7 +809,7 @@ pub struct HandoffChunk { /// Upload segments tile the chunk exactly (share CANDIDATE — safe to share /// only after fork-time content verification against `segs`). pub candidate: bool, - /// Chunk-relative `(start, end, crc)` upload segments (crc from [`fnv64`]). + /// Chunk-relative `(start, end, crc)` upload segments (crc from [`crate::fnv64`]). pub segs: Vec<(u64, u64, u64)>, /// Cached fork-time content-verification verdict (golden frozen → stable). pub verified: Option, @@ -1193,7 +1193,7 @@ fn mark_loaded_vmm(layout: &LayoutCell, dptr: u64, nbytes: u64, data: Option<&[u // and each chunk must record the hash of its own bytes (crc 0 = // unverifiable → never shared; used when bytes aren't dispatch-visible). let crc = data.map_or(0, |d| { - fnv64(&d[(abs_s - dptr) as usize..(abs_e - dptr) as usize]) + crate::fnv64(&d[(abs_s - dptr) as usize..(abs_e - dptr) as usize]) }); let (s, e) = (abs_s - base, abs_e - base); // An overlapping re-upload invalidates the prior segment's CRC for its @@ -1248,7 +1248,7 @@ fn module_cache_put(image: &[u8]) { return; // over budget: first-come wins; the big early fatbins matter most } let key = ModuleCacheKey { - fnv: fnv64(image), + fnv: crate::fnv64(image), len: image.len() as u64, head: image[..8].try_into().unwrap(), tail: image[image.len() - 8..].try_into().unwrap(), @@ -1261,15 +1261,6 @@ fn module_cache_get(key: &ModuleCacheKey) -> Option>> { module_cache().lock().unwrap().get(key).cloned() } -pub fn fnv64(data: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in data { - h ^= b as u64; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - h.max(1) -} - /// Mark the allocation containing `dptr` as loaded (H2D-written → read-only /// weight). Called on every host-to-device copy on the golden. fn mark_loaded(table: &AllocTable, dptr: u64) { diff --git a/crates/smolvm-cuda/src/lib.rs b/crates/smolvm-cuda/src/lib.rs index 57a134cc8..418191ac5 100644 --- a/crates/smolvm-cuda/src/lib.rs +++ b/crates/smolvm-cuda/src/lib.rs @@ -15,6 +15,20 @@ pub mod proto; /// Shared-memory command/completion rings (low-latency in-VM transport). pub mod ring; +/// FNV-1a 64-bit hash of `data`, never zero (0 is reserved as a sentinel). +/// +/// Lives at the crate root so both the always-compiled `client` (content-hash +/// module dedup) and the feature-gated `host` (module cache keys, chunk CRCs) +/// share one implementation without `client` reaching into `host`. +pub fn fnv64(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in data { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h.max(1) +} + /// Fingerprint of the wire-defining source (see `build.rs`). The client sends /// it in the `Init` handshake; the host rejects a mismatch, turning a stale /// shim/server pairing into a loud error instead of silent data corruption. diff --git a/crates/smolvm-protocol/src/guest_env.rs b/crates/smolvm-protocol/src/guest_env.rs index 10d25dcc6..a5f0b87c5 100644 --- a/crates/smolvm-protocol/src/guest_env.rs +++ b/crates/smolvm-protocol/src/guest_env.rs @@ -80,3 +80,31 @@ pub const DOCKER_SOCKET: &str = "SMOLVM_DOCKER_SOCKET"; /// by [`crate::publish_socket::encode`] (`port|dir|guest_path;…`). The agent /// decodes it on startup and spawns one relay per entry. Absent means none. pub const PUBLISH_SOCKETS: &str = "SMOLVM_PUBLISH_SOCKETS"; + +/// Enables the guest-side raw X11 socket bridge: the agent creates a local X11 +/// display socket in the guest and relays each connection out to the host X +/// server over the `ports::X11` vsock port, so guest X clients render on the +/// host X server. The agent also exports `DISPLAY` for the workload. +/// +/// This is a boolean sentinel — the value is [`VALUE_ON`] when set. +pub const X11: &str = "SMOLVM_X11"; + +/// Enables the guest-side waypipe Wayland forwarding daemon: the agent runs +/// `waypipe server` in daemon mode, which creates a Wayland display socket in +/// the guest and forwards every client that connects to it out over the +/// `ports::WAYPIPE` vsock port to the host `waypipe client`. The agent also +/// exports `WAYLAND_DISPLAY` (and `XDG_RUNTIME_DIR`) for the workload. +/// +/// Unlike X11, waypipe is a guest binary dependency usually installed after +/// boot, so the daemon starts lazily on the first launch once `waypipe` is on +/// PATH. +/// +/// This is a boolean sentinel — the value is [`VALUE_ON`] when set. +pub const WAYPIPE: &str = "SMOLVM_WAYPIPE"; + +/// Selects which `waypipe` binary the guest daemon runs. When set to a +/// non-empty absolute path, the daemon execs that binary (the host binary the +/// launcher shared into the guest via `WAYPIPE_TAG`, bind-mounted into the +/// container). When unset or empty, the daemon uses `waypipe` from the +/// container's `PATH` (the image's own install). +pub const WAYPIPE_BIN: &str = "SMOLVM_WAYPIPE_BIN"; diff --git a/crates/smolvm-protocol/src/lib.rs b/crates/smolvm-protocol/src/lib.rs index 0cb07aa71..f1b3cfb7a 100644 --- a/crates/smolvm-protocol/src/lib.rs +++ b/crates/smolvm-protocol/src/lib.rs @@ -61,6 +61,17 @@ pub const ROSETTA_TAG: &str = "rosetta"; /// both the wrapper and the `binfmt_misc` registration. pub const ROSETTA_GUEST_PATH: &str = "/mnt/rosetta"; +/// virtiofs tag for the host `waypipe` binary, shared into the guest so the +/// agent can run `waypipe server` in its own namespace with the exact same +/// binary the host `waypipe client` uses (avoiding wire-version drift between a +/// bundled guest waypipe and the user's host one). Shared host↔guest so the +/// launcher's `krun_add_virtiofs` tag and the guest mount source can't diverge. +pub const WAYPIPE_TAG: &str = "waypipe"; + +/// Guest mount point for the shared host `waypipe` binary. The agent runs +/// `/waypipe server` as the forwarding daemon. +pub const WAYPIPE_GUEST_PATH: &str = "/mnt/waypipe"; + /// Maximum frame size (32 MB - layer exports use chunked streaming). pub const MAX_FRAME_SIZE: u32 = 32 * 1024 * 1024; @@ -157,6 +168,17 @@ pub mod ports { /// Maximum number of user-published sockets per VM. Bounds the vsock-port /// window (`6100..6100+MAX`) below CUDA's 7000. pub const PUBLISH_SOCKET_MAX: usize = 64; + + /// Waypipe Wayland forwarding: the guest runs `waypipe server --vsock` and + /// connects out to this port; libkrun bridges it to a host Unix socket where + /// `waypipe client` listens next to the host compositor. Outbound (guest + /// connects out), like the SSH/DNS/CUDA bridges. + pub const WAYPIPE: u32 = 7001; + /// Raw X11 socket bridge: a guest X client connects out to this port and + /// libkrun bridges it straight to the host X server's Unix socket + /// (`/tmp/.X11-unix/X`), so guest X11 apps render on the host X server + /// with no waypipe in between. Outbound (guest connects out). + pub const X11: u32 = 7002; } /// vsock CID constants. diff --git a/crates/smolvm-smolfile/src/lib.rs b/crates/smolvm-smolfile/src/lib.rs index a652c195a..7e5fc0218 100644 --- a/crates/smolvm-smolfile/src/lib.rs +++ b/crates/smolvm-smolfile/src/lib.rs @@ -247,6 +247,16 @@ pub struct Smolfile { /// Expose the guest's Docker daemon socket to the host as a Unix socket /// (`DOCKER_HOST=unix://…`). Requires dockerd running inside the VM. pub docker_socket: Option, + /// Enable waypipe Wayland forwarding over vsock: render guest GUI apps on + /// the host compositor. Requires waypipe inside the VM. + pub waypipe: Option, + /// Which `waypipe` binary the guest daemon runs: `"host"` (default, share + /// the host binary), `"container"` (use the image's own `waypipe`), or an + /// absolute path to a host binary. Ignored unless `waypipe` is set. + pub waypipe_bin: Option, + /// Bridge the guest X11 socket straight to the host X server over vsock, so + /// guest X11 apps render on the host X server with no waypipe involved. + pub x11: Option, /// Storage disk size in GiB. pub storage: Option, /// Overlay disk size in GiB. diff --git a/src/agent/boot_config.rs b/src/agent/boot_config.rs index 46e3d8605..eb5fb5ce2 100644 --- a/src/agent/boot_config.rs +++ b/src/agent/boot_config.rs @@ -59,6 +59,21 @@ pub struct BootConfig { /// carried into the boot subprocess and applied by the launcher. #[serde(default)] pub published_sockets: Vec, + /// Enable waypipe Wayland forwarding. When set, the boot subprocess derives + /// a `waypipe.sock` in the VM data dir and registers an outbound vsock port + /// (guest connects out); the user runs a `waypipe client` on the host socket. + #[serde(default)] + pub waypipe: bool, + /// Which `waypipe` binary the guest daemon runs: `None`/`"host"` shares the + /// host binary, `"container"` uses the image's own, or an absolute host + /// path. Ignored unless `waypipe` is set. + #[serde(default)] + pub waypipe_bin: Option, + /// Enable the raw X11 socket bridge. When set, the boot subprocess resolves + /// the host X server socket from `$DISPLAY` and registers an outbound vsock + /// port bridged straight to it (guest connects out). + #[serde(default)] + pub x11: bool, /// Hostnames for DNS filtering. When set, the host starts a DNS filter /// listener and the guest agent proxies DNS queries through it. #[serde(default)] diff --git a/src/agent/launcher.rs b/src/agent/launcher.rs index f733289df..2bf2e6713 100644 --- a/src/agent/launcher.rs +++ b/src/agent/launcher.rs @@ -186,6 +186,18 @@ pub struct LaunchFeatures { /// the VM data dir (`DOCKER_HOST=unix://…`). The guest agent proxies each /// host connection to its in-guest `/var/run/docker.sock`. pub expose_docker: bool, + /// Enable waypipe Wayland forwarding: smolvm bridges a guest waypipe vsock + /// port to a host Unix socket in the VM data dir, where the user runs a + /// `waypipe client` next to the host compositor. + pub waypipe: bool, + /// Which `waypipe` binary the guest daemon runs: `None`/`"host"` shares the + /// host binary into the guest, `"container"` uses the image's own, or an + /// absolute host path shares that specific binary. Ignored unless `waypipe`. + pub waypipe_bin: Option, + /// Enable the raw X11 socket bridge: smolvm bridges a guest X11 vsock port + /// straight to the host X server's Unix socket, so guest X clients render on + /// the host X server with no waypipe involved. + pub x11: bool, /// Hostnames for DNS filtering. When set, the host starts a DNS filter /// listener and the guest agent proxies DNS queries through it. pub dns_filter_hosts: Option>, @@ -453,6 +465,21 @@ pub struct LaunchConfig<'a> { /// assigns a vsock port (`ports::PUBLISH_SOCKET_BASE + i`), wires libkrun, /// and encodes the guest side into `SMOLVM_PUBLISH_SOCKETS`. pub published_sockets: &'a [crate::config::PublishedSocketConfig], + /// Host-side waypipe socket. When set, libkrun bridges the guest's outbound + /// waypipe vsock port to this `AF_UNIX` path, where a `waypipe client` + /// listens next to the host compositor. Derived in the per-VM dir at the + /// boot-config boundary, so the launcher stays policy-free. + pub waypipe_socket: Option<&'a Path>, + /// Which `waypipe` binary the guest daemon runs: `None`/`"host"` shares the + /// host binary into the guest, `"container"` uses the image's own, or an + /// absolute host path shares that specific binary. Ignored unless + /// `waypipe_socket` is set. + pub waypipe_bin: Option<&'a str>, + /// Host X server socket (`/tmp/.X11-unix/X`). When set, libkrun bridges + /// the guest's outbound X11 vsock port straight to this existing host + /// socket. Resolved from the host `$DISPLAY` at the boot-config boundary; + /// unlike the others it is the live X server socket and is never unlinked. + pub x11_socket: Option<&'a Path>, /// Pre-extracted OCI layers directory for .smolmachine-sourced machines. /// Mounted via virtiofs as "smolvm_layers" so the agent uses packed layers. pub packed_layers_dir: Option<&'a Path>, @@ -513,6 +540,9 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> { cuda_socket, docker_socket, published_sockets, + waypipe_socket, + waypipe_bin, + x11_socket, packed_layers_dir, extra_disks, dns_filter_enabled, @@ -1157,6 +1187,8 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> { dns_filter_socket: dns_filter_socket.as_deref(), cuda_socket: cuda_socket.as_deref(), docker_socket: docker_socket.as_deref(), + waypipe_socket: waypipe_socket.as_deref(), + x11_socket: x11_socket.as_deref(), }; let active_vsock: Vec<_> = vsock_service::registry() .iter() @@ -1463,6 +1495,58 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> { } } + // Waypipe binary source selection. The guest daemon always runs inside + // the workload container (the only place with glibc; the agent rootfs is + // musl and cannot exec a glibc binary). The source picks WHICH binary + // the container runs: + // - "container" -> the image's own `waypipe` on PATH; nothing to + // share, and no SMOLVM_WAYPIPE_BIN env (guest falls + // back to PATH lookup). + // - "host"/None -> share the host `waypipe` into the guest via + // virtiofs; the guest bind-mounts it into the + // container and runs it. + // - "/abs/path" -> same as host, but that specific binary. + // Best-effort: if staging fails (no host waypipe, path missing), we skip + // the share and leave the daemon to fall back to the container's PATH + // rather than aborting the launch. + // + // `waypipe_guest_bin` carries the in-guest binary path to inject as + // SMOLVM_WAYPIPE_BIN below; `None` means "use the container's PATH". + let mut waypipe_guest_bin: Option = None; + if let Some(vmdir) = waypipe_socket.and_then(|s| s.parent()) { + let source = waypipe_bin.unwrap_or("host"); + if source != "container" { + // "host" resolves via PATH; an absolute path is used verbatim. + let src_path = if source == "host" { + crate::vm::waypipe::host_binary() + } else { + Some(std::path::PathBuf::from(source)) + }; + match src_path.and_then(|p| crate::vm::waypipe::stage_host_binary(vmdir, &p).ok()) { + Some(_dir) => { + let tag = cstr(smolvm_protocol::WAYPIPE_TAG); + // virtiofs shares the staging DIR; the guest mounts it at + // WAYPIPE_GUEST_PATH and runs /waypipe. + let host_path = cstr(&vmdir.join("waypipe-bin").to_string_lossy()); + if krun_add_virtiofs(ctx, tag.as_ptr(), host_path.as_ptr()) < 0 { + tracing::warn!("krun_add_virtiofs failed for waypipe binary; falling back to container PATH"); + } else { + waypipe_guest_bin = Some(format!( + "{}/waypipe", + smolvm_protocol::WAYPIPE_GUEST_PATH + )); + } + } + None => { + tracing::warn!( + source = source, + "could not stage waypipe binary; falling back to container PATH" + ); + } + } + } + } + boot_timing!("devices configured"); // Set working directory @@ -1526,6 +1610,13 @@ pub fn launch_agent_vm(config: &LaunchConfig<'_>) -> Result<()> { env_strings.push(cstr(&format!("{}={}", guest_env::PUBLISH_SOCKETS, encoded))); } + // Waypipe binary path is dynamic (depends on the shared-vs-container + // source decision above), so it rides as a normal env rather than a + // static vsock guest_env pair. Absent => guest uses the container PATH. + if let Some(bin) = &waypipe_guest_bin { + env_strings.push(cstr(&format!("{}={}", guest_env::WAYPIPE_BIN, bin))); + } + // Tell the agent GPU was requested so it can sanity-check the // virtio-gpu device actually appeared in the guest. libkrun // happily accepts `krun_set_gpu_options2` even if the embedded diff --git a/src/agent/manager.rs b/src/agent/manager.rs index 9f4b8ff28..3f33b808d 100644 --- a/src/agent/manager.rs +++ b/src/agent/manager.rs @@ -1849,6 +1849,9 @@ impl AgentManager { cuda: features.cuda || resources_for_config.cuda, expose_docker: features.expose_docker, published_sockets: features.published_sockets, + waypipe: features.waypipe, + waypipe_bin: features.waypipe_bin, + x11: features.x11, dns_filter_hosts: features.dns_filter_hosts, packed_layers_dir: features.packed_layers_dir, pack_idmap_source, diff --git a/src/agent/vsock_service.rs b/src/agent/vsock_service.rs index 19fc95bc5..1e606785e 100644 --- a/src/agent/vsock_service.rs +++ b/src/agent/vsock_service.rs @@ -25,6 +25,16 @@ pub struct VsockServiceInputs<'a> { pub dns_filter_socket: Option<&'a Path>, /// Host CUDA-over-vsock server socket (experimental). pub cuda_socket: Option<&'a Path>, + /// Host waypipe socket. libkrun bridges the guest's outbound waypipe vsock + /// port to this host `AF_UNIX` path, where a `waypipe client` listens next + /// to the host compositor. When set, waypipe forwarding is enabled. + pub waypipe_socket: Option<&'a Path>, + /// Host X11 server socket (`/tmp/.X11-unix/X`). libkrun bridges the + /// guest's outbound X11 vsock port straight to this existing host socket, so + /// guest X clients render on the host X server. When set, the raw X11 bridge + /// is enabled. Unlike the other endpoints this path is NOT smolvm-owned (it + /// is the live host X server socket), so it must never be unlinked. + pub x11_socket: Option<&'a Path>, /// Host-side Docker socket to expose. libkrun *listens* on this path and /// forwards each host connection to the guest, which proxies it to the /// in-guest dockerd socket. When set, the Docker bridge is enabled. @@ -107,6 +117,60 @@ impl VsockService for CudaService { } } +/// Waypipe Wayland forwarding: the guest runs `waypipe server` in daemon mode +/// and connects out to the host, where a `waypipe client` (plain unix mode) +/// listens on the bridged socket next to the host compositor. Outbound like +/// CUDA/DNS, so `listen: false`. +/// +/// The guest agent is told to start its daemon via `SMOLVM_WAYPIPE=1`: it runs +/// `waypipe server` with `--display`, which creates a Wayland display socket in +/// the guest and forwards every client that connects (one daemon for all apps), +/// and exports `WAYLAND_DISPLAY` for the workload — so guest GUI apps work with +/// no manual per-app `waypipe server`. The daemon starts lazily because waypipe +/// is usually installed in the guest after boot. +struct WaypipeService; +impl VsockService for WaypipeService { + fn resolve<'a>(&self, inputs: &VsockServiceInputs<'a>) -> Option> { + inputs.waypipe_socket.map(|socket| ActiveVsockService { + name: "Waypipe Wayland forwarding", + port: ports::WAYPIPE, + listen: false, + socket, + guest_env: &[( + smolvm_protocol::guest_env::WAYPIPE, + smolvm_protocol::guest_env::VALUE_ON, + )], + }) + } +} + +/// Raw X11 socket bridge: a guest X client connects out and libkrun bridges the +/// vsock port straight to the host X server's Unix socket. No waypipe, no +/// server/client pair - X was designed for network transparency, so the bytes +/// pass through unmodified. Outbound like waypipe, so `listen: false`. Note that +/// a plain byte bridge cannot carry SCM_RIGHTS ancillary fds, so MIT-SHM and +/// DRI3 fall back to wire-image transport (correctness preserved, perf reduced). +/// +/// The guest agent is told to start its bridge via `SMOLVM_X11=1`: on boot it +/// binds a local X11 display socket (`:10`) and relays each connection out to +/// this vsock port, and exports `DISPLAY=:10` for the workload - so guest X +/// clients work with no manual `socat`. +struct X11Service; +impl VsockService for X11Service { + fn resolve<'a>(&self, inputs: &VsockServiceInputs<'a>) -> Option> { + inputs.x11_socket.map(|socket| ActiveVsockService { + name: "X11 socket bridge", + port: ports::X11, + listen: false, + socket, + guest_env: &[( + smolvm_protocol::guest_env::X11, + smolvm_protocol::guest_env::VALUE_ON, + )], + }) + } +} + /// Docker socket bridge: the guest serves on the vsock port (proxying to its /// own dockerd socket) and the host connects in via the exposed Unix socket, so /// a host client can drive the guest's Docker daemon with `DOCKER_HOST=unix://…`. @@ -130,6 +194,8 @@ pub fn registry() -> &'static [&'static dyn VsockService] { &SshAgentService, &DnsFilterService, &CudaService, + &WaypipeService, + &X11Service, &DockerSocketService, ] } diff --git a/src/cli/internal_boot.rs b/src/cli/internal_boot.rs index d1fb04f5f..aef9781fb 100644 --- a/src/cli/internal_boot.rs +++ b/src/cli/internal_boot.rs @@ -591,6 +591,60 @@ pub fn run(config_path: PathBuf) -> smolvm::Result<()> { None }; + // Waypipe Wayland forwarding: bridge the guest's outbound waypipe vsock port + // to a Unix socket in the per-VM dir. Outbound (listen=false), so libkrun + // connects to this path when the guest opens the port; the user runs a + // `waypipe client` listening on it. Clear any stale socket first. + let waypipe_socket: Option = if config.waypipe { + config.vsock_socket.parent().map(|dir| { + let path = dir.join("waypipe.sock"); + let _ = std::fs::remove_file(&path); + path + }) + } else { + None + }; + + // Raw X11 socket bridge: resolve the host X server socket from the host + // `$DISPLAY` and bridge the guest's outbound X11 vsock port straight to it. + // The path is the LIVE host X server socket, not smolvm-owned, so it is + // never removed. Skips (with a warning) if $DISPLAY is unset/unparseable or + // the socket is missing, so a misconfigured host disables X11 rather than + // aborting the boot. + let x11_socket: Option = if config.x11 { + match host_x11_socket() { + Some(path) if path.exists() => { + tracing::info!(path = %path.display(), "X11 bridge: using host X server socket"); + Some(path) + } + Some(path) => { + tracing::warn!( + path = %path.display(), + "X11 bridge requested but the host X server socket does not exist - X11 disabled" + ); + None + } + None => { + tracing::warn!( + "X11 bridge requested but $DISPLAY is unset or not a local display - X11 disabled" + ); + None + } + } + } else { + None + }; + + // Waypipe host client: when forwarding is enabled, start a `waypipe client` + // on the host that listens on `waypipe.sock` and forwards to the host + // compositor, so the user does not run it by hand. Held for the VM's + // lifetime (dropped when this process exits); `spawn_client` also arms + // PR_SET_PDEATHSIG so it dies with the boot process. No-op off Linux / with + // no compositor. + let _waypipe_client = waypipe_socket + .as_deref() + .and_then(smolvm::vm::waypipe::spawn_client); + proc_timing!("ready to launch"); // Egress telemetry lands in the per-VM dir (the vsock socket's parent), the @@ -612,6 +666,9 @@ pub fn run(config_path: PathBuf) -> smolvm::Result<()> { cuda_socket: cuda_socket.as_deref(), docker_socket: docker_socket.as_deref(), published_sockets: &config.published_sockets, + waypipe_socket: waypipe_socket.as_deref(), + waypipe_bin: config.waypipe_bin.as_deref(), + x11_socket: x11_socket.as_deref(), packed_layers_dir: config.packed_layers_dir.as_deref(), extra_disks: &config.extra_disks, dns_filter_enabled: config @@ -731,3 +788,25 @@ mod backing_chain_tests { let _ = std::fs::remove_dir_all(&dir); } } + +/// Resolve the host X server's Unix socket path from the host `$DISPLAY`. +/// +/// Handles the common local forms `:N` and `:N.S` (screen suffix ignored), +/// mapping display N to `/tmp/.X11-unix/XN`. Returns `None` for a network +/// display (`host:N`, which has no local socket) or an unparseable value. The +/// boot subprocess inherits `$DISPLAY` from the launching user's environment. +fn host_x11_socket() -> Option { + let display = std::env::var("DISPLAY").ok()?; + // Strip an optional host part before ':'. A non-empty host means a TCP + // display, which has no local Unix socket to bridge. + let (host, rest) = display.rsplit_once(':')?; + if !host.is_empty() { + return None; + } + // rest is "N" or "N.S"; take the display number before any screen suffix. + let num = rest.split('.').next()?; + if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some(std::path::PathBuf::from(format!("/tmp/.X11-unix/X{num}"))) +} diff --git a/src/cli/machine.rs b/src/cli/machine.rs index f474bd2b6..a69b8b6cd 100644 --- a/src/cli/machine.rs +++ b/src/cli/machine.rs @@ -536,6 +536,20 @@ pub struct RunCmd { #[arg(long, help_heading = "Network")] pub docker_socket: bool, + /// Forward guest Wayland apps to the host compositor over vsock. Run a + /// `waypipe client` on the host socket next to your compositor; the guest + /// daemon and WAYLAND_DISPLAY are set up automatically. Optional value picks + /// the binary: `host` (default, share the host waypipe), `container` (use + /// the image's own), or a path to a host waypipe binary. + #[arg(long, help_heading = "Hardware", num_args = 0..=1, default_missing_value = "host", value_name = "host|container|PATH")] + pub waypipe: Option, + + /// Bridge the guest X11 socket straight to the host X server (no waypipe). + /// Resolves the host $DISPLAY at launch and bridges a guest vsock port to + /// that X server socket. Requires a running host X server. + #[arg(long, help_heading = "Hardware")] + pub x11: bool, + /// Mount ~/.docker/ config into VM for registry authentication #[arg(long, help_heading = "Registry")] pub docker_config: bool, @@ -1212,10 +1226,27 @@ impl RunCmd { }; let uses_packed_layers = packed_layers_dir.is_some(); + // Waypipe needs a workload container to host its daemon: the guest agent + // rootfs is musl Alpine and cannot exec a glibc `waypipe`, so the daemon + // runs inside the (glibc) container. A bare VM (no image) has no such + // container, so forwarding could never start. Reject up front rather than + // booting with SMOLVM_WAYPIPE set and silently doing nothing. + if (self.waypipe.is_some() || params.waypipe) && image.is_none() { + return Err(smolvm::Error::agent( + "waypipe", + "--waypipe requires an --image: the forwarding daemon runs inside the \ + workload container, which a bare VM does not have. Re-run with \ + --image (the image must provide, or let you install, waypipe).", + )); + } + let mut features = smolvm::agent::LaunchFeatures { ssh_agent_socket, cuda: self.cuda || params.cuda, expose_docker: self.docker_socket || params.docker_socket, + waypipe: self.waypipe.is_some() || params.waypipe, + waypipe_bin: self.waypipe.clone().or_else(|| params.waypipe_bin.clone()), + x11: self.x11 || params.x11, dns_filter_hosts: params.dns_filter_hosts.clone(), packed_layers_dir, extra_disks: Vec::new(), @@ -1244,6 +1275,39 @@ impl RunCmd { ); } + // Tell the user how to use the Wayland bridge. Both ends are wired up + // automatically: the guest agent runs `waypipe server` as a daemon and + // exports WAYLAND_DISPLAY, and on Linux smolvm starts the matching host + // `waypipe client` next to the compositor. The host client is skipped + // when $WAYLAND_DISPLAY is unset or no `waypipe` is on the host PATH; a + // manual fallback for that case is shown below. + if self.waypipe.is_some() || params.waypipe { + let sock = smolvm::agent::vm_data_dir(&vm_name).join("waypipe.sock"); + eprintln!( + "Waypipe forwarding enabled (guest Wayland apps -> host compositor). \ + Both ends are set up automatically - just run a GUI app in the VM:\n \ + smolvm machine exec -- weston-terminal\n\ + If the host client wasn't started (no $WAYLAND_DISPLAY, or no waypipe \ + on the host PATH), run one yourself:\n \ + waypipe -s {} client", + sock.display() + ); + } + + // Tell the user how to use the raw X11 bridge. smolvm bridges the guest's + // outbound X11 vsock port straight to the host X server socket (resolved + // from $DISPLAY), and the guest agent exposes it as a local display and + // exports DISPLAY automatically - no socat needed. X11 auth still applies: + // allow the guest with `xhost +local:` on the host (or copy the MIT cookie). + if self.x11 || params.x11 { + eprintln!( + "X11 bridge enabled (host $DISPLAY -> guest display :10). \ + DISPLAY is set in the VM automatically, so just run an X app:\n \ + smolvm machine exec -- xterm\n \ + If clients are refused, run `xhost +local:` on the host first." + ); + } + // Register the ephemeral VM for tracking (machine list, orphan cleanup), // keyed by the VM's OWN name. The orphan sweep only has the DB record and // locates the disks via `vm_data_dir(record.name)`, so the record name @@ -1480,6 +1544,12 @@ impl RunCmd { ssh_agent: self.ssh_agent || params.ssh_agent, cuda: self.cuda || params.cuda, docker_socket: self.docker_socket || params.docker_socket, + waypipe: self.waypipe.is_some() || params.waypipe, + waypipe_bin: self + .waypipe + .clone() + .or_else(|| params.waypipe_bin.clone()), + x11: self.x11 || params.x11, dns_filter_hosts: params.dns_filter_hosts.clone(), gpu: self.gpu || params.gpu, gpu_vram_mib: self.gpu_vram_mib.or(params.gpu_vram_mib), @@ -1633,6 +1703,12 @@ impl RunCmd { ssh_agent: self.ssh_agent || params.ssh_agent, cuda: self.cuda || params.cuda, docker_socket: self.docker_socket || params.docker_socket, + waypipe: self.waypipe.is_some() || params.waypipe, + waypipe_bin: self + .waypipe + .clone() + .or_else(|| params.waypipe_bin.clone()), + x11: self.x11 || params.x11, dns_filter_hosts: params.dns_filter_hosts.clone(), gpu: self.gpu || params.gpu, gpu_vram_mib: self.gpu_vram_mib.or(params.gpu_vram_mib), @@ -2342,6 +2418,20 @@ pub struct CreateCmd { #[arg(long)] pub docker_socket: bool, + /// Forward guest Wayland apps to the host compositor over vsock. Run a + /// `waypipe client` on the host socket next to your compositor; the guest + /// daemon and WAYLAND_DISPLAY are set up automatically. Optional value picks + /// the binary: `host` (default, share the host waypipe), `container` (use + /// the image's own), or a path to a host waypipe binary. + #[arg(long, num_args = 0..=1, default_missing_value = "host", value_name = "host|container|PATH")] + pub waypipe: Option, + + /// Bridge the guest X11 socket straight to the host X server (no waypipe). + /// Resolves the host $DISPLAY at launch and bridges a guest vsock port to + /// that X server socket. Requires a running host X server. + #[arg(long)] + pub x11: bool, + /// Inject a secret from a host env var (GUEST_VAR=HOST_VAR), resolved at /// each launch. Only the reference is persisted, never the value. #[arg(long = "secret-env", value_name = "GUEST_VAR=HOST_VAR")] @@ -2492,6 +2582,25 @@ impl CreateCmd { if self.docker_socket { params.docker_socket = true; } + if self.waypipe.is_some() { + params.waypipe = true; + params.waypipe_bin = self.waypipe.clone(); + } + // Waypipe's daemon runs inside the workload container (the musl agent + // rootfs cannot exec a glibc waypipe). A bare VM has no container, so + // reject --waypipe without an image rather than persisting a machine + // whose forwarding can never start. See the same guard on `run`. + if params.waypipe && params.image.is_none() { + return Err(smolvm::Error::agent( + "waypipe", + "--waypipe requires an --image: the forwarding daemon runs inside the \ + workload container, which a bare VM does not have. Re-create with \ + --image (the image must provide, or let you install, waypipe).", + )); + } + if self.x11 { + params.x11 = true; + } if self.gpu { params.gpu = true; } @@ -2653,6 +2762,9 @@ impl CreateCmd { ssh_agent: self.ssh_agent, cuda: self.cuda, docker_socket: self.docker_socket, + waypipe: self.waypipe.is_some(), + waypipe_bin: self.waypipe.clone(), + x11: self.x11, dns_filter_hosts: None, published_sockets: Vec::new(), gpu: manifest.gpu, diff --git a/src/cli/smolfile.rs b/src/cli/smolfile.rs index c81e2b647..7b23a01ea 100644 --- a/src/cli/smolfile.rs +++ b/src/cli/smolfile.rs @@ -87,6 +87,9 @@ pub fn build_create_params( ssh_agent: false, cuda: false, docker_socket: false, + waypipe: false, + waypipe_bin: None, + x11: false, gpu: false, gpu_vram_mib: None, rosetta: false, @@ -283,6 +286,9 @@ pub fn build_create_params( ssh_agent: sf.auth.as_ref().and_then(|a| a.ssh_agent).unwrap_or(false), cuda: sf.cuda.unwrap_or(false), docker_socket: sf.docker_socket.unwrap_or(false), + waypipe: sf.waypipe.unwrap_or(false), + waypipe_bin: sf.waypipe_bin.clone(), + x11: sf.x11.unwrap_or(false), gpu, gpu_vram_mib: sf.gpu_vram, rosetta, diff --git a/src/cli/vm_common.rs b/src/cli/vm_common.rs index 3fd38d88f..b0559d90d 100644 --- a/src/cli/vm_common.rs +++ b/src/cli/vm_common.rs @@ -447,6 +447,14 @@ pub struct CreateVmParams { pub cuda: bool, /// Expose the guest's Docker daemon socket to the host as a Unix socket. pub docker_socket: bool, + /// Enable waypipe Wayland forwarding over vsock (guest GUI apps on the host + /// compositor). + pub waypipe: bool, + /// Which `waypipe` binary the guest daemon runs: `None`/`"host"` shares the + /// host binary, `"container"` uses the image's own, or an absolute path. + pub waypipe_bin: Option, + /// Bridge the guest X11 socket straight to the host X server over vsock. + pub x11: bool, /// Enable GPU acceleration (virtio-gpu with Venus/Vulkan). pub gpu: bool, /// GPU VRAM size in MiB (None = default). Ignored when gpu is false. @@ -642,6 +650,9 @@ pub(crate) fn build_vm_record(params: &CreateVmParams) -> smolvm::Result, + /// Bridge the guest X11 socket straight to the host X server over vsock. + pub x11: bool, pub dns_filter_hosts: Option>, pub gpu: bool, pub gpu_vram_mib: Option, diff --git a/src/config.rs b/src/config.rs index 73ed6b9e8..65033bd6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -514,6 +514,25 @@ pub struct VmRecord { #[serde(default)] pub docker_socket: bool, + /// Enable waypipe Wayland forwarding over vsock: smolvm bridges a guest + /// waypipe vsock port to a host Unix socket, so guest GUI apps render on the + /// host compositor via `waypipe client` (host) and `waypipe server --vsock` + /// (guest). + #[serde(default)] + pub waypipe: bool, + + /// Which `waypipe` binary the guest daemon runs. `None` or `"host"` shares + /// the host binary into the guest; `"container"` uses the image's own + /// `waypipe`; an absolute path shares that specific host binary. Ignored + /// unless `waypipe` is set. + #[serde(default)] + pub waypipe_bin: Option, + + /// Bridge the guest X11 socket straight to the host X server over vsock, so + /// guest X11 apps render on the host X server with no waypipe involved. + #[serde(default)] + pub x11: bool, + /// Hostnames for DNS filtering. When set, the guest DNS proxy filters /// queries against this allowlist. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -622,6 +641,9 @@ impl VmRecord { ssh_agent: false, cuda: false, docker_socket: false, + waypipe: false, + waypipe_bin: None, + x11: false, dns_filter_hosts: None, ephemeral: false, source_smolmachine: None, @@ -679,6 +701,9 @@ impl VmRecord { ssh_agent: false, cuda: false, docker_socket: false, + waypipe: false, + waypipe_bin: None, + x11: false, dns_filter_hosts: None, ephemeral: false, source_smolmachine: None, diff --git a/src/cuda_daemon.rs b/src/cuda_daemon.rs index 4b81ae407..9aeaa8dc5 100644 --- a/src/cuda_daemon.rs +++ b/src/cuda_daemon.rs @@ -1367,7 +1367,7 @@ fn verify_chunk_content(b: &mut dyn Backend, ch: &smolvm_cuda::host::HandoffChun Ok(bytes) => ch.segs.iter().all(|&(s, e, crc)| { crc != 0 && e as usize <= bytes.len() - && smolvm_cuda::host::fnv64(&bytes[s as usize..e as usize]) == crc + && smolvm_cuda::fnv64(&bytes[s as usize..e as usize]) == crc }), Err(e) => { tracing::warn!(e, va = ch.va, "M2-share: verify D2H failed → private"); diff --git a/src/vm/mod.rs b/src/vm/mod.rs index e04caa597..97a588936 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -9,6 +9,7 @@ pub mod backend; pub mod config; pub mod rosetta; pub mod state; +pub mod waypipe; use crate::error::Result; pub use config::{ diff --git a/src/vm/waypipe.rs b/src/vm/waypipe.rs new file mode 100644 index 000000000..7de4187b0 --- /dev/null +++ b/src/vm/waypipe.rs @@ -0,0 +1,161 @@ +//! Host-side staging for the shared `waypipe` binary. +//! +//! When `--waypipe` is enabled, the guest agent runs `waypipe server` as the +//! forwarding daemon. To avoid wire-version drift between a waypipe bundled in +//! the guest and the user's host `waypipe client`, the guest reuses the *host* +//! binary: this module locates it on the host and stages it in a dedicated +//! directory that the launcher shares into the guest via virtiofs (virtiofs +//! shares a directory, not a single file, hence the staging dir). + +use std::path::{Path, PathBuf}; +use std::process::Child; + +/// Locate the host `waypipe` binary on `PATH`. +pub fn host_binary() -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join("waypipe"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +/// Stage a host `waypipe` binary (`src`) into a virtiofs-shareable directory +/// under the VM data dir, returning that directory. The guest mounts it +/// read-only at [`smolvm_protocol::WAYPIPE_GUEST_PATH`] and runs `/waypipe`. +/// +/// Copies rather than symlinks so the shared tree is self-contained (virtiofs +/// would otherwise expose a dangling link into the host filesystem). Skips the +/// copy when an up-to-date staged binary is already present. +pub fn stage_host_binary(vmdir: &Path, src: &Path) -> std::io::Result { + if !src.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("waypipe binary not found at {}", src.display()), + )); + } + + let dir = vmdir.join("waypipe-bin"); + std::fs::create_dir_all(&dir)?; + let dst = dir.join("waypipe"); + + // Re-copy only when missing or the source is newer / a different size, so + // repeated boots do not pay the copy each time. + if needs_copy(&src, &dst)? { + std::fs::copy(&src, &dst)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))?; + } + } + + Ok(dir) +} + +/// Whether `dst` must be (re)written from `src`. +fn needs_copy(src: &Path, dst: &Path) -> std::io::Result { + let dst_meta = match std::fs::metadata(dst) { + Ok(m) => m, + Err(_) => return Ok(true), + }; + let src_meta = std::fs::metadata(src)?; + if src_meta.len() != dst_meta.len() { + return Ok(true); + } + // If we can compare mtimes and the source is newer, recopy; otherwise trust + // the size match (a same-size, same-or-older staged copy is good enough). + match (src_meta.modified(), dst_meta.modified()) { + (Ok(s), Ok(d)) => Ok(s > d), + _ => Ok(false), + } +} + +/// Spawn a host `waypipe client` that listens on `socket` (plain unix mode) and +/// forwards to the host Wayland compositor. libkrun bridges the guest's outbound +/// waypipe vsock port to `socket`, so the client speaks plain unix here and +/// libkrun handles the vsock translation. Returns the child so the boot process +/// can keep it alive for the VM's lifetime; the child is armed with +/// `PR_SET_PDEATHSIG` so the kernel kills it when the boot process dies (the +/// boot path exits via `_exit`, which skips Drop, so kill-on-drop is not enough). +/// +/// Linux-only: the host client only makes sense where a Wayland compositor runs. +/// Returns `None` (with a warning) when the host has no `waypipe`, no +/// `WAYLAND_DISPLAY`, or the spawn fails — a misconfigured host disables the +/// host-side automation rather than aborting the boot, matching the X11 bridge. +#[cfg(target_os = "linux")] +pub fn spawn_client(socket: &Path) -> Option { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + tracing::warn!( + "waypipe host client requested but $WAYLAND_DISPLAY is unset - \ + not starting a host client (run `waypipe -s {} client` yourself)", + socket.display() + ); + return None; + } + + let bin = match host_binary() { + Some(bin) => bin, + None => { + tracing::warn!( + "waypipe host client requested but no `waypipe` binary is on the host PATH - \ + not starting a host client" + ); + return None; + } + }; + + // The client creates the socket; remove any stale one first so `waypipe` + // does not refuse to bind. `internal_boot` also clears it, but this keeps + // the spawn self-contained. + let _ = std::fs::remove_file(socket); + + let mut cmd = std::process::Command::new(&bin); + cmd.arg("-s") + .arg(socket) + .arg("client") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + // Tie the client's lifetime to this boot process: when the boot process + // dies, the kernel sends SIGKILL to the client. Without this the client + // would be reparented to init and leak after the VM is gone. + #[cfg(target_os = "linux")] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // SIGKILL == 9. prctl(PR_SET_PDEATHSIG, ...) is async-signal-safe. + if libc::prctl(libc::PR_SET_PDEATHSIG, 9) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + + match cmd.spawn() { + Ok(child) => { + tracing::info!( + socket = %socket.display(), + binary = %bin.display(), + "waypipe host client started" + ); + Some(child) + } + Err(e) => { + tracing::warn!("failed to start waypipe host client: {e} - not forwarding"); + None + } + } +} + +/// Non-Linux hosts have no Wayland compositor to forward to, so the host client +/// is never started there. +#[cfg(not(target_os = "linux"))] +pub fn spawn_client(_socket: &Path) -> Option { + None +}