8000 feat(minimald,sandbox2): rootless host-native (DM2) own-ip networking by norrietaylor · Pull Request #589 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8478420
feat(minimald,sandbox2): rootless host-native (DM2) own-ip networking
norrietaylor Jun 28, 2026
a87acc5
build(initramfs): build guest minimald natively when host arch matches
norrietaylor Jun 28, 2026
fbeb596
chore: add dm1/dm2/dm3 deployment-model bring-up recipes
norrietaylor Jun 28, 2026
0272c63
build(deps): pin hakoniwa gominimal fork, hold clap at 4.5 for RustSlirp
norrietaylor Jul 1, 2026
5bd31c2
refactor(minimald,sandbox2): rootless own-ip via hakoniwa RustSlirp
norrietaylor Jul 1, 2026
59ec3c1
test(minvmd): deflake host gvproxy liveness tests
norrietaylor Jul 1, 2026
114320f
refactor(minimald,sandbox2,switch): address own-ip review feedback
norrietaylor Jul 1, 2026
eceb97c
build(initramfs): set CC_<target> for native musl builds
norrietaylor Jul 1, 2026
cb90a72
build(deps): repoint hakoniwa to upstream souk4711
norrietaylor Jul 1, 2026
a89b026
fix(minimald,sandbox2): keep own-ip DNS for the DM1/3/4 shuttle path
norrietaylor Jul 1, 2026
67ef89d
test(spec-networking): fix e2e harness pty-multiplexing artifact
norrietaylor Jul 2, 2026
9b050ef
test(spec-networking): fix TC2 verdict masking (CodeRabbit)
norrietaylor Jul 2, 2026
548a508
feat(minimald): register OwnIp hostnames via published-loopback model…
norrietaylor Jul 2, 2026
6e8e50d
test(minimald): pin the detach-vs-exit network-lifetime invariant
norrietaylor Jul 2, 2026
33c8517
test(spec-networking): rework harness for detach-not-exit + UC2a publ…
norrietaylor Jul 2, 2026
530f22f
docs(spec-networking): host->PTask via published-loopback ports (R3.1…
norrietaylor Jul 2, 2026
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
468 changes: 388 additions & 80 deletions Cargo.lock

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,13 @@ boringtun = { version = "0.7", default-features = false }
bytes = "1.11" # keep in sync with reqwest
bzip2 = "0.6"
camino = { version = "1", features = ["serde1"] }
clap = { version = "4.6", features = ["derive", "env", "string"] }
clap_complete = { version = "4.6" }
# Held at 4.5 (not 4.6): clap_derive 4.6 requires proc-macro2 >= 1.0.106, which
# cannot unify with c2rust-bitfields 0.22's exact `proc-macro2 = "=1.0.103"` pin
# (pulled in via hakoniwa's rustslirp feature -> tun-rs 2.8.3). clap_derive 4.5
# needs only proc-macro2 ^1.0, letting the resolver settle on 1.0.103. Bump back
# to 4.6 once tun-rs/c2rust relax that pin.
clap = { version = "4.5", features = ["derive", "env", "string"] }
clap_complete = { version = "4.5" }
codespan-reporting = { version = "0.13", features = ["serialization"] } # Keep in sync with nickel-lang-core
constcat = "0.6"
copy_dir = "0.1.3"
Expand All @@ -73,8 +78,12 @@ generational-arena = {version = "0.2", features = ["serde"] }
google-cloud-gax = "1.13"
google-cloud-auth = "1.5"
google-cloud-storage = "1.7"
# Revert back to crates.io dep once new version is released.
hakoniwa = { git = "https://github.com/souk4711/hakoniwa.git", rev = "41ce36e087fa1fcf28d17123042d150e71c09705", features = ["cgroups"] }
# Pinned to upstream souk4711/hakoniwa for the RustSlirp `gateway()`
# next-hop-route support (`RustSlirpGateway::IfaceWithAddr`) that own-IP egress
# needs. (The rustslirp feature's tun-rs 2.8.3 -> c2rust-bitfields 0.22 ->
# proc-macro2 =1.0.103 pin is accommodated by holding clap at 4.5; see the clap
# note above.) Move to a crates.io release once one carrying this is published.
hakoniwa = { git = "https://github.com/souk4711/hakoniwa.git", rev = "bc0abab1b5ac91bb2dcf285ccd1b1beeb80a8f42", features = ["cgroups", "rustslirp"] }
http-body = "1"
hex = "0.4"
indicatif = "0.18"
Expand Down
30 changes: 30 additions & 0 deletions crates/minimald/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ pub struct EnvArgs {
env_vars: Option<HashMap<String, EnvVarValue>>,
ot: Option<OpTracker>,
network_mode: NetworkMode,
own_ip_tap: Option<sandbox2::config::OwnIpTap>,
own_ip_dns: Option<std::net::Ipv4Addr>,
}

impl EnvArgs {
Expand All @@ -93,6 +95,8 @@ impl EnvArgs {
env_vars: None,
ot: None,
network_mode: NetworkMode::HostNet,
own_ip_tap: None,
own_ip_dns: None,
}
}

Expand Down Expand Up @@ -142,6 +146,25 @@ impl EnvArgs {
self.network_mode = mode;
self
}

/// Sets the own-IP user-mode tap parameters (native/DM2 own-IP). When set,
/// the sandbox's TAP is created + configured in-namespace by hakoniwa
/// (rootless) and the caller relays its fd to the switch. `None` keeps the
/// host/VM behaviour.
#[must_use]
pub fn with_own_ip_tap(mut self, tap: Option<sandbox2::config::OwnIpTap>) -> Self {
self.own_ip_tap = tap;
self
}

/// Sets the own-IP DNS server for the sandbox's `/etc/resolv.conf` (the
/// switch gateway). Set for every own-IP sandbox, independent of the tap
/// params, so both the DM2 and DM1/3/4 own-IP paths get a working resolver.
#[must_use]
pub fn with_own_ip_dns(mut self, dns: Option<std::net::Ipv4Addr>) -> Self {
self.own_ip_dns = dns;
self
}
}

/// A fully-owned, session-scoped runtime environment.
Expand Down Expand Up @@ -244,6 +267,13 @@ impl Env {
.with_state_dir(args.state_base_dir.as_utf8_path())
.with_env_vars(pkg_env_vars.into_iter())
.with_network_mode(args.network_mode)
// Own-IP tap params drive hakoniwa's in-namespace (rootless) tap on
// the native DM2 path; `None` for host/VM modes and the DM1/3/4
// vsock-shuttle path (which keeps the privileged move-into-netns
// wiring). The DNS server is set separately for *every* own-IP
// sandbox so both paths get a working resolver.
.with_own_ip_tap(args.own_ip_tap)
.with_own_ip_dns(args.own_ip_dns)
.with_hostname(args.name.clone())
.with_username(args.username.unwrap_or_else(|| "user".to_string()));

Expand Down
4 changes: 2 additions & 2 deletions crates/minimald/src/guest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,11 @@ pub async fn bring_up_root_egress() -> std::io::Result<crate::net::switch::Switc
// Bring loopback up too (no address/route needed).
configure_interface_v4("lo", Ipv4Addr::LOCALHOST, 8, None)?;

// Point the resolver at gvproxy's gateway, which serves DNS for the switch.
// Point the resolver at the switch's DNS server (gvproxy, at the gateway).
// The rootfs is mounted read-only, so write to the writable /run tmpfs and
// bind-mount it over /etc/resolv.conf (a bind only changes the mount tree, so
// it works over a read-only fs as long as the target path exists).
if let Err(e) = install_resolv_conf(gateway) {
if let Err(e) = install_resolv_conf(DEFAULT_SUBNET.dns_server()) {
tracing::warn!(error = %e, "installing /etc/resolv.conf for guest egress (DNS may fail)");
}

Expand Down
11 changes: 10 additions & 1 deletion crates/minimald/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ pub struct ListenArgs {
/// `minimal` CLI to auto-start a native (DM2) daemon on Linux.
#[arg(long, default_value_t = false)]
detach: bool,

/// Path to the gvproxy ("gvisor-tap-vsock") binary backing the per-host
/// `OwnIp` switch. Defaults to the fixed system install path when unset;
/// point it at a local build to run own-IP (DM2) without a system install.
#[arg(long)]
gvproxy_bin: Option<std::path::PathBuf>,
}

/// An error at the top level of minimald.
Expand Down Expand Up @@ -301,6 +307,9 @@ async fn async_main() -> Result<(), MainError> {
mount_dev: true,
mount_rootfs: Some("/dev/vda".to_string()),
detach: false,
// In-VM (DM1/3/4) the PTask attaches to the host gvproxy over the
// vsock shuttle, so no in-guest gvproxy binary path is needed.
gvproxy_bin: None,
}),
global_args: GlobalArgs {
minimal_state_dir: Some(DaemonAbsPath::try_new("/run/minimal").unwrap().into()),
Expand Down Expand Up @@ -381,7 +390,7 @@ async fn async_main() -> Result<(), MainError> {
},
minimal_state_dir: cli.minimal_state_dir(),
minimal_cache_dir: cli.minimal_cache_dir(),
gvproxy_bin: None,
gvproxy_bin: listen_args.gvproxy_bin.clone(),
// 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. The UDS path is DM2.
Expand Down
47 changes: 43 additions & 4 deletions crates/minimald/src/net/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
//! microVM (libkrun) runtimes never resolve anything, and the TLD choice is
//! irrelevant to correctness.
//!
//! `HostNet` PTasks register to `127.0.0.1` (R3.6); `OwnIp` PTasks register to
//! their gvproxy switch IP (R3.1), which the proxy reaches through the switch
//! relay. [`HostnameRegistry::register`] takes the target address, so both modes
//! are the same registry call with a different target.
//! Both `HostNet` (R3.6) and `OwnIp` (R3.1) PTasks register to `127.0.0.1`: a
//! `HostNet` PTask's listeners are on host loopback directly, and an `OwnIp`
//! PTask is reached through a gvproxy-**published loopback port** (its forwarder
//! API binds `127.0.0.1:<external>` → `lease:<internal>`), so the daemon is never
//! on the switch (the DM2 topology in `networking-with-diagrams.md`). The client
//! selects the published external port; the registry gates only on the host.
//! [`HostnameRegistry::register`] takes the target address, so both modes are the
//! same registry call with the same `127.0.0.1` target.
//!
//! Covers R3.5 (structured register/deregister tracing) and R3.6 (`HostNet`
//! registration). The former systemd-resolved startup probe (R3.4) is removed by
Expand Down Expand Up @@ -137,6 +141,18 @@ impl HostnameRegistry {
self.register(session_id, session_name, LOOPBACK)
}

/// Registers an `OwnIp` PTask, routing its hostname to `127.0.0.1` (R3.1).
///
/// Under the published-loopback model (see the module docs) an `OwnIp` PTask's
/// service is reached through a gvproxy-published loopback port
/// (`127.0.0.1:<external>` → `lease:<internal>`), so — like a `HostNet` PTask
/// — its hostname resolves to loopback and the daemon never touches the
/// switch. The client selects the published external port; the registry gates
/// only on the host.
pub fn register_own_ip(&mut self, session_id: SessionId, session_name: &str) -> Hostname {
self.register(session_id, session_name, LOOPBACK)
}

/// Withdraws `session_name`'s hostname, returning it if one was registered.
/// Emits the R3.5 `deregistered` tracing event only when an entry is
/// actually removed, so calling it for an unregistered session is a silent
Expand Down Expand Up @@ -230,6 +246,29 @@ mod tests {
assert_eq!(reg.resolve("myservice.dev.min.internal"), None);
}

/// Under the published-loopback model an `OwnIp` PTask registers to
/// `127.0.0.1` (R3.1) — the same target as a `HostNet` PTask — because it is
/// reached through a gvproxy-published loopback port, not the switch IP. The
/// client then uses the published external port in the authority.
#[test]
fn own_ip_registration_routes_to_loopback() {
let mut reg = HostnameRegistry::new("dev");
let hostname = reg.register_own_ip(SessionId::nil(), "web");
assert_eq!(hostname.as_str(), "web.dev.min.internal");

let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
assert_eq!(reg.resolve("web.dev.min.internal"), Some(loopback));
// The published external port is carried in the authority; the registry
// gates on the host only.
assert_eq!(reg.resolve("web.dev.min.internal:18080"), Some(loopback));

assert_eq!(
reg.deregister("web").map(|h| h.as_str().to_string()),
Some("web.dev.min.internal".to_string())
);
assert_eq!(reg.resolve("web.dev.min.internal"), None);
}

/// Port stripping handles both the common `name:port` form and the
/// bracketed IPv6 literal form, where the port follows the closing bracket.
/// The registry never holds an IPv6 literal, so this guards the parse from
Expand Down
130 changes: 84 additions & 46 deletions crates/minimald/src/net/gvproxy_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

use std::future::Future;
use std::io;
use std::net::Ipv4Addr;
use std::os::fd::OwnedFd;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;

Expand Down Expand Up @@ -73,7 +76,7 @@ impl Network for GvproxyNetwork {
/// `OwnIp` PTask leaves). It is **explicit** — driven on a live runtime by the
/// owner — rather than a `Drop` schedule, so it cannot be lost to a stopped
/// runtime. Dropping the held [`SwitchRelay`] aborts the frame relay either way.
struct OwnIpGuard {
pub(crate) struct OwnIpGuard {
/// Held for its `Drop`, which aborts the relay tasks; never read.
_relay: SwitchRelay,
/// The shared switch, locked on teardown to detach this PTask.
Expand Down Expand Up @@ -103,34 +106,44 @@ impl NetGuard for OwnIpGuard {
}
}

/// Attaches an `OwnIp` PTask (identified by its sandbox process's netns-holding
/// PID) to the shared gvproxy switch: allocate a lease and ensure gvproxy is up,
/// open a host-side tap, move it into the PTask's network namespace and
/// configure its switch address there, then start the frame relay and apply any
/// static ingress.
/// Attaches an `OwnIp` PTask to the host-owned gvproxy switch over the vsock
/// shuttle — the **DM1/3/4 (`HostShuttle`) path**, used inside a libkrun VM where
/// minimald is root: allocate a lease and bump the attach count, open a host-side
/// tap, move it into the PTask's network namespace with `ip`/`nsenter` and
/// configure its switch address there, then relay its frames to the host gvproxy
/// and apply any static ingress.
///
/// The native DM2 (`LocalSpawn`) path does **not** go through here: it is rootless
/// (hakoniwa's RustSlirp builds the tap in-namespace) and is driven from the
/// session host via [`complete_local_own_ip_attach`].
async fn attach_own_ip(
switch: &Arc<Mutex<SwitchClient>>,
netns_pid: u32,
ingress: Option<&sessions::IngressPolicy>,
) -> io::Result<OwnIpGuard> {
use crate::net::SwitchTransport;
use crate::net::switch::{
attach_to_switch, attach_to_switch_vsock, move_tap_into_netns, open_tap,
};
use crate::net::switch::{attach_to_switch_vsock, move_tap_into_netns, open_tap};

// Allocate a lease and ensure gvproxy is running, snapshotting the control
// 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) = {
// Allocate a lease and bump the attach count, snapshotting the 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, subnet, transport) = {
let mut s = switch.lock().await;
let attach = s
.attach()
.await
.map_err(|e| io::Error::other(format!("attaching OwnIp PTask to switch: {e}")))?;
// 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(), s.transport())
(attach.lease, s.subnet(), s.transport())
};

// This function is the HostShuttle (in-VM) path; the native LocalSpawn path
// is handled rootless in the session host. Roll the attach back if a
// misconfigured caller routes a LocalSpawn switch here.
let SwitchTransport::HostShuttle { cid, port } = transport else {
let _ = switch.lock().await.detach().await;
return Err(io::Error::other(
"attach_own_ip is the HostShuttle path; LocalSpawn own-IP is handled in the session host",
));
};

// A locally-administered tap name unique within the switch /16 (its low two
Expand All @@ -144,16 +157,8 @@ 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?;
// 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. 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
}
}
// Relay the tap's raw frames over vsock to the host gvproxy `minvmd` owns.
attach_to_switch_vsock(tap_fd, cid, port).await
}
.await
{
Expand All @@ -164,31 +169,64 @@ async fn attach_own_ip(
}
};

// The control channel for the forwarder API: the local socket on DM2, the
// host gvproxy over the same vsock shuttle port on DM1/3/4 (the `-listen`
// socket serves `/services/forwarder/*` alongside the `/connect` relay).
let control = match transport {
SwitchTransport::LocalSpawn => ControlChannel::Unix(sock),
SwitchTransport::HostShuttle { cid, port } => ControlChannel::Vsock { cid, port },
};
// The forwarder API reaches the host gvproxy over the same vsock shuttle port
// (the `-listen` socket serves `/services/forwarder/*` alongside `/connect`).
// This path owns its own rollback (no session-host guard), so detach if the
// ingress-applying tail fails.
let control = ControlChannel::Vsock { cid, port };
match finish_own_ip_attach(switch, relay, control, lease.ip, ingress).await {
Ok(guard) => Ok(guard),
Err(e) => {
let _ = switch.lock().await.detach().await;
Err(e)
}
}
}

/// Completes a native (DM2/`LocalSpawn`) own-IP attach: relay the in-namespace
/// tap fd (created rootless by hakoniwa's RustSlirp and handed out via
/// `Child.rustslirp_tapfd`) to the local gvproxy `-listen` socket, then apply any
/// static ingress.
///
/// The lease was already allocated and gvproxy already ensured-running by the
/// pre-spawn `SwitchClient::attach()` (the IP had to be known before spawn to
/// configure the tap), so this only does the post-spawn relay + ingress and, on
/// failure, rolls the attach back. `lease_ip`/`sock` are the values snapshotted
/// from that pre-spawn attach.
pub(crate) async fn complete_local_own_ip_attach(
switch: &Arc<Mutex<SwitchClient>>,
tap_fd: OwnedFd,
sock: PathBuf,
lease_ip: Ipv4Addr,
ingress: Option<&sessions::IngressPolicy>,
) -> io::Result<OwnIpGuard> {
// Rollback of the pre-spawn phase-1 attach is owned by the session host's
// launch guard (which also covers a cancelled launch), so a failure here just
// propagates — detaching would double-decrement gvproxy's attach count.
let relay = crate::net::switch::attach_to_switch(tap_fd, &sock).await?;
let control = ControlChannel::Unix(sock);
finish_own_ip_attach(switch, relay, control, lease_ip, ingress).await
}

// R2.3/R2.4-static: with the PTask attached, apply its static ingress
// forwards via gvproxy's forwarder API, 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.
/// Shared tail of both own-IP attach paths: apply static ingress forwards (R2.3)
/// over `control`, then build the [`OwnIpGuard`]. On an ingress failure the relay
/// is dropped (closing the switch-side connection), but the attach-count rollback
/// is left to the caller — `attach_own_ip` (HostShuttle) detaches inline, while
/// the LocalSpawn path defers to the session host's launch guard so the refcount
/// is never double-decremented.
async fn finish_own_ip_attach(
switch: &Arc<Mutex<SwitchClient>>,
relay: SwitchRelay,
control: ControlChannel,
lease_ip: Ipv4Addr,
ingress: Option<&sessions::IngressPolicy>,
) -> io::Result<OwnIpGuard> {
let exposed = match ingress {
Some(ingress) if !ingress.port_mappings.is_empty() => {
match crate::net::policy::apply_ingress(&control, lease.ip, ingress).await {
match crate::net::policy::apply_ingress(&control, lease_ip, ingress).await {
Ok(exposed) => exposed,
Err(e) => {
drop(relay);
if let Err(det_err) = switch.lock().await.detach().await {
tracing::warn!(
error = %det_err,
ingress_err = %e,
"detaching OwnIp PTask from switch during ingress-apply rollback"
);
}
return Err(e);
}
}
Expand Down
Loading
Loading
0