8000 [WIP] feat(minvmd,minimald): host gvproxy + per-PTask vsock shuttle for DM1/3/4 (#572) by norrietaylor · Pull Request #577 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/minimald/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ async fn async_main() -> Result<(), MainError> {
minimal_state_dir: cli.minimal_state_dir(),
minimal_cache_dir: cli.minimal_cache_dir(),
gvproxy_bin: None,
// The vsock listen path is exactly the libkrun-VM (DM1/3/4) case: an
// `OwnIp` PTask must attach to the host gvproxy over the vsock shuttle,
// not spawn gvproxy in-guest (issue #572). The UDS path is DM2.
in_microvm: listen_args.vsock,
};
// Ensure the SSH host key is accessible in a instance-specific known_hosts file.
russh::keys::known_hosts::learn_known_hosts_path(
Expand Down
110 changes: 107 additions & 3 deletions crates/minimald/src/net/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,44 @@ pub const DEFAULT_MTU: u16 = 1500;
/// Stable, locally-administered MAC for the switch gateway.
pub const GATEWAY_MAC: MacAddr = MacAddr([0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xdd]);

/// AF_VSOCK CID of the host as seen from inside a libkrun guest. Well-known:
/// `VMADDR_CID_HOST == 2`. The per-PTask shuttle dials this CID to reach the
/// host gvproxy switch (issue #572, DM1/3/4).
pub const VSOCK_HOST_CID: u32 = 2;

/// vsock port the per-PTask shuttle connects to on the host to reach the host
/// gvproxy switch (issue #572). Must match `minvmd`'s
/// `net::VSOCK_GVPROXY_SHUTTLE_PORT`; libkrun bridges this guest-initiated
/// connection to the host gvproxy `-listen` socket.
pub const VSOCK_GVPROXY_SHUTTLE_PORT: u32 = 1024;

/// How the per-host gvproxy switch is reached, selected by deployment model
/// (issue #572).
///
/// On DM2 (native Linux) `minimald` owns gvproxy: it is spawned locally and
/// PTask taps attach over its `-listen` UNIX socket. On DM1/3/4 (a libkrun VM)
/// `minvmd` owns the host gvproxy; the in-guest `minimald` does **not** spawn
/// gvproxy — each PTask tap attaches over an AF_VSOCK shuttle to the host
/// switch. Keeping the two as an enum (rather than an `Option<sock>` plus a
/// bool) makes the illegal "spawn locally *and* shuttle to host" state
/// unrepresentable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SwitchTransport {
/// DM2: `minimald` spawns + owns gvproxy locally; taps attach over its UNIX
/// control socket.
LocalSpawn,
/// DM1/3/4: gvproxy runs on the host (owned by `minvmd`); taps attach over a
/// vsock shuttle to `(cid, port)`. `minimald` never spawns gvproxy here.
HostShuttle { cid: u32, port: u32 },
}

impl Default for SwitchTransport {
fn default() -> Self {
Self::LocalSpawn
}
}

/// How long to wait for gvproxy's control socket to appear after spawn.
const SOCKET_READY_TIMEOUT: Duration = Duration::from_secs(5);
/// How long to wait for gvproxy to exit after `SIGTERM` before escalating to
Expand Down Expand Up @@ -354,11 +392,15 @@ pub struct GvproxySwitch {
allocator: IpAllocator,
/// Number of attached PTasks; the switch runs while this is non-zero.
attached: usize,
/// The running gvproxy child, if started.
/// The running gvproxy child, if started. Always `None` in
/// [`SwitchTransport::HostShuttle`] mode (the host owns gvproxy).
child: Option<Child>,
/// Signals attached PTasks when gvproxy exits unexpectedly. Replaced
/// on each unexpected exit so new attachers get a fresh receiver.
exit_tx: watch::Sender<bool>,
/// How PTask taps reach the switch (issue #572): local spawn (DM2) or a
/// vsock shuttle to the host gvproxy (DM1/3/4).
transport: SwitchTransport,
}

impl GvproxySwitch {
Expand All @@ -384,9 +426,26 @@ impl GvproxySwitch {
attached: 0,
child: None,
exit_tx,
transport: SwitchTransport::default(),
}
}

/// Sets how PTask taps reach the switch (issue #572). The DM2 default is
/// [`SwitchTransport::LocalSpawn`]; in a libkrun VM (DM1/3/4) the caller sets
/// [`SwitchTransport::HostShuttle`] so this `minimald` attaches taps to the
/// host-owned gvproxy over vsock instead of spawning gvproxy in-guest.
#[must_use]
pub fn with_transport(mut self, transport: SwitchTransport) -> Self {
self.transport = transport;
self
}

/// How this switch's PTask taps reach the gvproxy switch (issue #572).
#[must_use]
pub fn transport(&self) -> SwitchTransport {
self.transport
}

/// The control socket path gvproxy listens on (host side only).
#[must_use]
pub fn control_socket(&self) -> PathBuf {
Expand Down Expand Up @@ -420,8 +479,13 @@ impl GvproxySwitch {
/// Propagates config-write, spawn, and socket-readiness failures.
pub async fn attach(&mut self) -> Result<AttachResult, NetError> {
let lease = self.allocator.allocate()?;
self.write_config().await?;
self.ensure_running().await?;
// DM2 spawns + configures gvproxy locally; DM1/3/4 (HostShuttle) leaves
// gvproxy to `minvmd` on the host, so skip the spawn/config steps and
// only track the attach count (issue #572).
if matches!(self.transport, SwitchTransport::LocalSpawn) {
self.write_config().await?;
self.ensure_running().await?;
}
self.attached += 1;
tracing::info!(
ip = %lease.ip,
Expand Down Expand Up @@ -717,4 +781,44 @@ mod tests {
assert!(cfg.contains("dhcpStaticLeases:"));
assert!(cfg.contains("{}"));
}

#[test]
fn transport_defaults_to_local_spawn() {
let switch = GvproxySwitch::new("/usr/bin/gvproxy", "/run/minimal/gvproxy");
assert_eq!(switch.transport(), SwitchTransport::LocalSpawn);
}

#[test]
fn with_transport_selects_host_shuttle() {
let switch = GvproxySwitch::new("/usr/bin/gvproxy", "/run/minimal/gvproxy").with_transport(
SwitchTransport::HostShuttle {
cid: VSOCK_HOST_CID,
port: VSOCK_GVPROXY_SHUTTLE_PORT,
},
);
assert_eq!(
switch.transport(),
SwitchTransport::HostShuttle { cid: 2, port: 1024 }
);
}

#[tokio::test]
async fn host_shuttle_attach_allocates_without_spawning_gvproxy() {
// In HostShuttle mode `minvmd` owns gvproxy, so `attach` must not try to
// spawn the (here nonexistent) binary: it allocates a lease and only
// tracks the attach count. A LocalSpawn switch pointed at the same
// missing binary would instead fail in `ensure_running`.
let dir = tempfile::TempDir::new().expect("tempdir");
let mut switch =
GvproxySwitch::new("/nonexistent/gvproxy-binary", dir.path().join("gvproxy"))
.with_transport(SwitchTransport::HostShuttle {
cid: VSOCK_HOST_CID,
port: VSOCK_GVPROXY_SHUTTLE_PORT,
});

let result = switch.attach().await.expect("host-shuttle attach");
assert_eq!(result.lease.ip, Ipv4Addr::new(100, 64, 0, 2));
// No gvproxy ch 8000 ild was spawned; detach just decrements the count.
switch.detach().await.expect("detach");
}
}
49 changes: 46 additions & 3 deletions crates/minimald/src/net/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,8 @@ impl Drop for SwitchRelay {
}
}

/// Attaches `tap_fd` to the gvproxy switch listening on `api_sock` and starts
/// relaying frames between them.
/// Attaches `tap_fd` to the gvproxy switch listening on `api_sock` (DM2, native
/// Linux) and starts relaying frames between them.
///
/// Spawns two background tasks — tap→switch and switch→tap — and returns a
/// [`SwitchRelay`] handle whose lifetime keeps the attachment alive.
Expand All @@ -287,7 +287,51 @@ impl Drop for SwitchRelay {
pub async fn attach_to_switch(tap_fd: OwnedFd, api_sock: &Path) -> io::Result<SwitchRelay> {
let mut sock = UnixStream::connect(api_sock).await?;
sock.write_all(CONNECT_REQUEST).await?;
let (sock_rx, sock_tx) = sock.into_split();
spawn_relay(tap_fd, sock_rx, sock_tx)
}

/// Attaches `tap_fd` to the **host** gvproxy switch over AF_VSOCK (issue #572,
/// DM1/3/4) and starts relaying frames between them.
///
/// On a libkrun VM the gvproxy switch runs on the host; the guest reaches it by
/// connecting to `cid` (CID 2 = the host) on `port`, which libkrun bridges to
/// the host gvproxy `-listen` socket (`minvmd` registers this via
/// `krun_add_vsock_port2(.., listen = false)`). This is the same HyperKit-framed
/// raw-L2 relay as [`attach_to_switch`] — the shuttle is *not* a second TCP/IP
/// stack — so exactly one gVisor stack (the host gvproxy) sits in the path.
///
/// # Errors
///
/// Returns an error if the vsock connection cannot be established, the connect
/// request cannot be written, or the tap fd cannot be put into non-blocking
/// mode for epoll-driven I/O.
pub async fn attach_to_switch_vsock(
tap_fd: OwnedFd,
cid: u32,
port: u32,
) -> io::Result<SwitchRelay> {
let mut sock =
tokio_vsock::VsockStream::connect(tokio_vsock::VsockAddr::new(cid, port)).await?;
// `VsockStream` has an inherent (blocking, std::io) `write_all` that shadows
// the async trait method, so disambiguate to the tokio trait — same hazard
// `guest.rs` notes for `shutdown`.
AsyncWriteExt::write_all(&mut sock, CONNECT_REQUEST).await?;
let (sock_rx, sock_tx) = tokio::io::split(sock);
spawn_relay(tap_fd, sock_rx, sock_tx)
}

/// Wires `tap_fd` into the bidirectional frame relay against an already-connected,
/// `/connect`-upgraded switch stream split into `sock_rx`/`sock_tx`.
///
/// Shared by the DM2 UDS path ([`attach_to_switch`]) and the DM1/3/4 vsock path
/// ([`attach_to_switch_vsock`]); the relay loops are transport-agnostic
/// (`AsyncRead`/`AsyncWrite`), so only the connect step differs.
fn spawn_relay<R, W>(tap_fd: OwnedFd, sock_rx: R, sock_tx: W) -> io::Result<SwitchRelay>
where
R: AsyncReadExt + Unpin + Send + 'static,
W: AsyncWriteExt + Unpin + Send + 'static,
{
// A tap character device does not support pread/pwrite, so `tokio::fs::File`
// (which routes through the blocking pool via positional I/O) cannot drive
// it. Use plain read/write in non-blocking mode with `AsyncFd` for epoll
Expand All @@ -298,7 +342,6 @@ pub async fn attach_to_switch(tap_fd: OwnedFd, api_sock: &Path) -> io::Result<Sw
set_nonblocking(tap_file.as_raw_fd())?;
let tap = Arc::new(AsyncFd::new(tap_file)?);

let (sock_rx, sock_tx) = sock.into_split();
let tap_to_switch = tokio::spawn(relay_tap_to_switch(Arc::clone(&tap), sock_tx));
let switch_to_tap = tokio::spawn(relay_switch_to_tap(sock_rx, tap));
Ok(SwitchRelay {
Expand Down
28 changes: 24 additions & 4 deletions crates/minimald/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ pub struct Config {
/// to [`DEFAULT_GVPROXY_BIN`] when unset.
#[serde(default)]
pub gvproxy_bin: Option<PathBuf>,
/// Whether this `minimald` runs inside a `minvmd` libkrun VM (DM1/3/4). When
/// `true`, `OwnIp` PTasks attach to the **host** gvproxy (owned by `minvmd`)
/// over a vsock shuttle instead of spawning gvproxy in-guest (issue #572).
/// `false` (DM2, native Linux) keeps the local-spawn + tap relay path.
#[serde(default)]
pub in_microvm: bool,
}

impl Config {
Expand Down Expand Up @@ -119,10 +125,24 @@ impl ServerState {
// live under a dedicated subdir of the daemon state dir. The shared
// `Arc` is the single source of truth, injected into every per-launch
// `SandboxLauncher` through the sessions manager.
let net_switch = Arc::new(Mutex::new(crate::net::GvproxySwitch::new(
config.gvproxy_bin_path(),
minimal_state_dir.as_utf8_path().join("gvproxy"),
)));
// DM1/3/4 (in a libkrun VM): attach `OwnIp` PTasks to the host gvproxy
// (owned by `minvmd`) over a vsock shuttle. DM2 (native Linux): spawn +
// own gvproxy locally (issue #572).
let transport = if config.in_microvm {
crate::net::SwitchTransport::HostShuttle {
cid: crate::net::VSOCK_HOST_CID,
port: crate::net::VSOCK_GVPROXY_SHUTTLE_PORT,
}
} else {
crate::net::SwitchTransport::LocalSpawn
};
let net_switch = Arc::new(Mutex::new(
crate::net::GvproxySwitch::new(
config.gvproxy_bin_path(),
minimal_state_dir.as_utf8_path().join("gvproxy"),
)
.with_transport(transport),
));

// Generate the TLS CA once at daemon startup so the HTTPS proxy and the
// IssueClientCert RPC share the same trust anchor for the lifetime of
Expand Down
43 changes: 37 additions & 6 deletions crates/minimald/src/session_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,12 +654,15 @@ async fn attach_own_ip(
netns_pid: u32,
ingress: Option<&sessions::IngressPolicy>,
) -> io::Result<OwnIpAttachment> {
use crate::net::switch::{attach_to_switch, move_tap_into_netns, open_tap};
use crate::net::SwitchTransport;
use crate::net::switch::{
attach_to_switch, attach_to_switch_vsock, move_tap_into_netns, open_tap,
};

// Allocate a lease and ensure gvproxy is running, snapshotting the control
// socket and subnet under one lock; the slow tap/relay work runs unlocked so
// concurrent attaches don't serialize on the namespace plumbing.
let (lease, sock, subnet) = {
// socket, subnet, and transport under one lock; the slow tap/relay work runs
// unlocked so concurrent attaches don't serialize on the namespace plumbing.
let (lease, sock, subnet, transport) = {
let mut s = switch.lock().await;
let attach = s
.attach()
Expand All @@ -668,7 +671,7 @@ async fn attach_own_ip(
// gvproxy's unexpected-exit closes the control socket, which ends the
// relay's switch-side read on its own, so the relay self-terminates; we
// do not additionally watch `attach.exit_signal` here.
(attach.lease, s.control_socket(), s.subnet())
(attach.lease, s.control_socket(), s.subnet(), s.transport())
};

// A locally-administered tap name unique within the switch /16 (its low two
Expand All @@ -682,7 +685,16 @@ async fn attach_own_ip(
let relay = match async {
let tap_fd = open_tap(&tap)?;
move_tap_into_netns(&tap, netns_pid, lease, subnet).await?;
attach_to_switch(tap_fd, &sock).await
// DM2 attaches the tap to the local gvproxy `-listen` socket; DM1/3/4
// (HostShuttle) relays the tap's raw frames over vsock to the host
// gvproxy `minvmd` owns (issue #572). Both are the same HyperKit-framed
// L2 relay — one gVisor stack in the path either way.
match transport {
SwitchTransport::LocalSpawn => attach_to_switch(tap_fd, &sock).await,
SwitchTransport::HostShuttle { cid, port } => {
attach_to_switch_vsock(tap_fd, cid, port).await
}
}
}
.await
{
Expand All @@ -697,7 +709,26 @@ async fn attach_own_ip(
// forwards on the switch control socket, retaining handles to remove on
// exit. A failure here rolls the whole attach back (drop the relay, then
// detach) so a half-configured PTask is never left running.
//
// Issue #572 gap: ingress port-forwards are applied via gvproxy's HTTP API
// on its control socket. On DM2 that socket is local; on DM1/3/4
// (HostShuttle) gvproxy is on the host and its API is not reachable from the
// guest over the frame-only shuttle. Egress (the focus of #572) needs no
// such call, so for now ingress on the VM path is skipped with a warning
// rather than silently appearing to work — a host-side ingress API path is
// follow-up work.
let exposed = match ingress {
Some(ingress)
if !ingress.port_mappings.is_empty()
&& matches!(transport, SwitchTransport::HostShuttle { .. }) =>
{
tracing::warn!(
ip = %lease.ip,
"static ingress on a VM (DM1/3/4) own-IP PTask is not yet wired to the \
host gvproxy port-forward API; egress works, ingress is skipped (issue #572)"
);
Vec::new()
}
Comment on lines 720 to +731
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't silently accept unsupported VM ingress.

When port_mappings are present in HostShuttle mode, this still returns a successful attachment and just drops the requested forwards. That leaves callers with a running own-IP PTask whose ingress contract is broken. Fail the attach/request explicitly until the host-side gvproxy API path exists.

Suggested direction
         Some(ingress)
             if !ingress.port_mappings.is_empty()
                 && matches!(transport, SwitchTransport::HostShuttle { .. }) =>
         {
-            tracing::warn!(
-                ip = %lease.ip,
-                "static ingress on a VM (DM1/3/4) own-IP PTask is not yet wired to the \
-                 host gvproxy port-forward API; egress works, ingress is skipped (issue `#572`)"
-            );
-            Vec::new()
+            return Err(io::Error::new(
+                io::ErrorKind::Unsupported,
+                "static ingress for own-IP PTasks is not supported in microvm mode yet",
+            ));
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let exposed = match ingress {
Some(ingress)
if !ingress.port_mappings.is_empty()
&& matches!(transport, SwitchTransport::HostShuttle { .. }) =>
{
tracing::warn!(
ip = %lease.ip,
"static ingress on a VM (DM1/3/4) own-IP PTask is not yet wired to the \
host gvproxy port-forward API; egress works, ingress is skipped (issue #572)"
);
Vec::new()
}
let exposed = match ingress {
Some(ingress)
if !ingress.port_mappings.is_empty()
&& matches!(transport, SwitchTransport::HostShuttle { .. }) =>
{
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"static ingress for own-IP PTasks is not supported in microvm mode yet",
));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/session_host.rs` around lines 720 - 731, The HostShuttle
branch in session_host::attach currently logs a warning and returns an empty
ingress list when ingress.port_mappings are requested, which silently accepts an
unsupported VM ingress setup. Update this path to fail the attach/request
explicitly instead of returning Vec::new(), so callers of the own-IP PTask are
told the ingress contract is unsupported until the gvproxy host-side forwarding
path exists. Use the ingress handling in session_host::attach and the
SwitchTransport::HostShuttle match arm as the locating symbols.

Some(ingress) if !ingress.port_mappings.is_empty() => {
match crate::net::policy::apply_ingress(&sock, lease.ip, ingress).await {
Ok(exposed) => exposed,
Expand Down
1 change: 1 addition & 0 deletions crates/minimald/src/test_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ impl TestServer {
minimal_state_dir: state_dir,
minimal_cache_dir: cache_dir,
gvproxy_bin: None,
in_microvm: false,
};
let state = ServerStateHandle::new(config).await.unwrap();

Expand Down
17 changes: 17 additions & 0 deletions crates/minvmd/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ pub const VSOCK_MARKER_PORT: u32 = 7350;
/// READY marker from the parent to the VMM child.
pub const MARKER_SOCK_ENV: &str = "MINVMD_MARKER_SOCK";

/// Environment variable selecting an own-IP VM (issue #572). When set to a
/// truthy value (`1`/`true`), the supervisor spawns the host gvproxy switch and
/// the VMM child registers the per-PTask shuttle vsock bridge + sets the VM's
/// network mode to `OwnIp`. Read by both the parent supervisor (to decide
/// whether to spawn gvproxy) and the VMM child (to configure the VM), so the two
/// processes stay consistent. Unset/false ⇒ a `HostNet` VM with no host gvproxy.
pub const OWN_IP_ENV: &str = "MINVMD_VM_OWN_IP";

/// Whether [`OWN_IP_ENV`] requests an own-IP VM (issue #572).
#[must_use]
pub fn own_ip_requested() -> bool {
matches!(
std::env::var(OWN_IP_ENV).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE")
)
}

/// Verify the host hypervisor backend is accessible before booting a VM (R2.4).
///
/// On Linux, libkrun drives KVM, which needs a readable `/dev/kvm`. Probe it
Expand Down
Loading
Loading
0