From 22f5707bda4b1a423b3b07d9f8a6b448d0b13a3a Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 22:27:32 +0000 Subject: [PATCH 1/3] host: add --port flag for inbound listen-port allowlist Signed-off-by: danbugs --- examples/networking-py/Justfile | 4 +- host/examples/pyhl_as_library.rs | 2 +- host/src/bin/pyhl.rs | 45 ++++++- host/src/lib.rs | 210 ++++++++++++++++++++++++++++--- host/src/main.rs | 22 +++- host/src/pyhl.rs | 22 +++- 6 files changed, 275 insertions(+), 30 deletions(-) diff --git a/examples/networking-py/Justfile b/examples/networking-py/Justfile index ee4c364..8e11a2d 100644 --- a/examples/networking-py/Justfile +++ b/examples/networking-py/Justfile @@ -26,9 +26,9 @@ run-urllib: run-https: hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /https_test.py -# Run echo server +# Run echo server (--port implies --net) run-echo: - hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /echo_server.py + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --port 8080 -- /echo_server.py # Build rootfs via Docker (cross-platform) rootfs: diff --git a/host/examples/pyhl_as_library.rs b/host/examples/pyhl_as_library.rs index 578ddfb..4e4f59f 100644 --- a/host/examples/pyhl_as_library.rs +++ b/host/examples/pyhl_as_library.rs @@ -22,7 +22,7 @@ fn main() -> anyhow::Result<()> { // expose host directories via the guest's hostfs. let mounts: &[Preopen] = &[]; - let mut rt = pyhl::Runtime::new(&home, mounts, None)?; + let mut rt = pyhl::Runtime::new(&home, mounts, None, None)?; eprintln!("-- first run (hermetic from loaded snapshot) --"); let t1 = rt.run_code(&code)?; diff --git a/host/src/bin/pyhl.rs b/host/src/bin/pyhl.rs index 3589030..ff5bdd1 100644 --- a/host/src/bin/pyhl.rs +++ b/host/src/bin/pyhl.rs @@ -26,7 +26,7 @@ use hyperlight_unikraft::pyhl::{ copy_replace, discover_source_artifacts, extract_from_ghcr, GHCR_INITRD_IMAGE, GHCR_KERNEL_IMAGE, }; -use hyperlight_unikraft::{AllowList, BlockList, NetworkPolicy, Preopen, Sandbox}; +use hyperlight_unikraft::{AllowList, BlockList, ListenPorts, NetworkPolicy, Preopen, Sandbox}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -41,6 +41,7 @@ fn build_network_policy( net: bool, net_allow: &[String], net_block: &[String], + has_ports: bool, ) -> Result> { if !net_allow.is_empty() { Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts( @@ -50,13 +51,21 @@ fn build_network_policy( Ok(Some(NetworkPolicy::BlockList(BlockList::from_hosts( net_block, )?))) - } else if net { + } else if net || has_ports { Ok(Some(NetworkPolicy::AllowAll)) } else { Ok(None) } } +fn build_listen_ports(ports: &[u16]) -> Option { + if ports.is_empty() { + None + } else { + Some(ListenPorts::from_ports(ports.iter().copied())) + } +} + /// Keep in sync with `py_initialize_once` in examples/python-agent-driver/ /// hl_pydriver.c. These modules are imported during `pyhl setup`'s warmup /// so they're already in `sys.modules` in every `pyhl run` invocation — @@ -187,6 +196,12 @@ struct SetupArgs { conflicts_with = "net_allow" )] net_block: Vec, + + /// Allow the guest to bind (listen) on the given port. Implies --net. + /// Without this flag, `net_bind` is rejected (outbound-only). + /// Repeatable: `--port 8080 --port 3000`. + #[arg(long, value_name = "PORT")] + port: Vec, } #[derive(Args)] @@ -236,6 +251,12 @@ struct RunArgs { )] net_block: Vec, + /// Allow the guest to bind (listen) on the given port. Implies --net. + /// Without this flag, `net_bind` is rejected (outbound-only). + /// Repeatable: `--port 8080 --port 3000`. + #[arg(long, value_name = "PORT")] + port: Vec, + /// Print evolve/warmup/per-run timing to stderr. Off by default so the /// user's script output is clean. #[arg(short = 'v', long = "verbose")] @@ -393,7 +414,13 @@ fn cmd_setup(args: SetupArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; - let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?; + let listen_ports = build_listen_ports(&args.port); + let network = build_network_policy( + args.net, + &args.net_allow, + &args.net_block, + listen_ports.is_some(), + )?; eprintln!("pyhl: warming up Python and persisting snapshot…"); let t_warm = Instant::now(); @@ -407,6 +434,9 @@ fn cmd_setup(args: SetupArgs) -> Result<()> { if let Some(ref policy) = network { builder = builder.network(policy.clone()); } + if let Some(ref lp) = listen_ports { + builder = builder.listen_ports(lp.clone()); + } let mut sbox = builder.build()?; sbox.restore()?; let _: () = sbox.call_named("run", "pass".to_string())?; @@ -531,7 +561,13 @@ fn cmd_run(args: RunArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; - let network = build_network_policy(args.net, &args.net_allow, &args.net_block)?; + let listen_ports = build_listen_ports(&args.port); + let network = build_network_policy( + args.net, + &args.net_allow, + &args.net_block, + listen_ports.is_some(), + )?; let initrd = home.join(INITRD_FILE); @@ -546,6 +582,7 @@ fn cmd_run(args: RunArgs) -> Result<()> { &run_preopens, initrd_ref, network.as_ref(), + listen_ports.as_ref(), )?; if args.verbose { eprintln!( diff --git a/host/src/lib.rs b/host/src/lib.rs index 718bc44..ac2e082 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -325,6 +325,42 @@ impl NetworkPolicy { } } +// --------------------------------------------------------------------------- +// Listen-port allowlist (inbound) +// --------------------------------------------------------------------------- + +/// Controls which ports a guest may bind to for inbound connections. +/// +/// Orthogonal to [`NetworkPolicy`] (which governs *outbound* destinations). +/// Without a `ListenPorts` allowlist, `net_bind` / `net_listen` / +/// `net_accept` are still registered but `net_bind` rejects every call. +#[derive(Clone, Debug)] +pub struct ListenPorts { + ports: HashSet, +} + +impl ListenPorts { + /// Create from an iterator of port numbers. + pub fn from_ports(ports: impl IntoIterator) -> Self { + Self { + ports: ports.into_iter().collect(), + } + } + + /// Returns `Ok(())` if `port` is in the allowlist. + fn check(&self, port: u16) -> Result<()> { + if self.ports.contains(&port) { + Ok(()) + } else { + Err(anyhow!( + "Permission denied: port {} not in listen allowlist ({:?})", + port, + self.ports + )) + } + } +} + // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- @@ -968,6 +1004,7 @@ fn register_internal_tools( tools: &mut ToolRegistry, exit_code: &Arc, network: Option<&NetworkPolicy>, + listen_ports: Option<&ListenPorts>, ) { let ec = exit_code.clone(); tools.register("__hl_exit", move |args| { @@ -983,7 +1020,7 @@ fn register_internal_tools( Ok(serde_json::json!({})) }); if let Some(policy) = network { - register_net_tools(tools, policy); + register_net_tools(tools, policy, listen_ports); } } @@ -1060,7 +1097,11 @@ fn sockaddr_to_json(addr: SocketAddr) -> serde_json::Value { }) } -fn register_net_tools(tools: &mut ToolRegistry, policy: &NetworkPolicy) { +fn register_net_tools( + tools: &mut ToolRegistry, + policy: &NetworkPolicy, + listen_ports: Option<&ListenPorts>, +) { use base64::Engine; use serde_json::json; @@ -1108,11 +1149,16 @@ fn register_net_tools(tools: &mut ToolRegistry, policy: &NetworkPolicy) { Ok(json!({})) }); - // net_bind + // net_bind — gated by listen-port allowlist let t = table.clone(); + let lp = listen_ports.cloned().map(Arc::new); tools.register("net_bind", move |args| { let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; let addr = parse_sockaddr(&args)?; + match lp.as_ref() { + Some(ports) => ports.check(addr.port())?, + None => return Err(anyhow!("Permission denied: no --port specified for bind")), + } let sa: SockAddr = addr.into(); let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; @@ -1608,6 +1654,7 @@ pub struct SandboxBuilder { stack_size: Option, preopens: Vec, network: Option, + listen_ports: Option, tools: ToolRegistry, has_tools: bool, } @@ -1672,6 +1719,17 @@ impl SandboxBuilder { self } + /// Allow the guest to bind to the given ports for inbound connections. + /// + /// Requires [`network`](Self::network) to also be set — without a + /// network policy the net tools are not registered at all. When net + /// tools *are* registered but no `listen_ports` is set, `net_bind` + /// rejects every call (outbound-only mode). + pub fn listen_ports(mut self, ports: ListenPorts) -> Self { + self.listen_ports = Some(ports); + self + } + /// Register a host function callable from the guest via `__dispatch`. pub fn tool(mut self, name: &str, handler: F) -> Self where @@ -1694,6 +1752,7 @@ impl SandboxBuilder { None }; let net = self.network.as_ref(); + let lp = self.listen_ports.as_ref(); match self.initrd { Some(InitrdSource::File(path)) => Sandbox::evolve_mapped( &self.kernel, @@ -1703,6 +1762,7 @@ impl SandboxBuilder { tools, &self.preopens, net, + lp, ), Some(InitrdSource::Bytes(bytes)) => Sandbox::evolve_inline( &self.kernel, @@ -1712,6 +1772,7 @@ impl SandboxBuilder { tools, &self.preopens, net, + lp, ), None => Sandbox::evolve_mapped( &self.kernel, @@ -1721,6 +1782,7 @@ impl SandboxBuilder { tools, &self.preopens, net, + lp, ), } } @@ -1738,6 +1800,7 @@ impl Sandbox { stack_size: None, preopens: Vec::new(), network: None, + listen_ports: None, tools: ToolRegistry::new(), has_tools: false, } @@ -1752,6 +1815,7 @@ impl Sandbox { tools: Option, preopens: &[Preopen], network: Option<&NetworkPolicy>, + listen_ports: Option<&ListenPorts>, ) -> Result { if !kernel_path.exists() { return Err(anyhow!("Kernel not found: {:?}", kernel_path)); @@ -1767,7 +1831,7 @@ impl Sandbox { let exit_code = Arc::new(AtomicI32::new(0)); let mut tools = build_tools(tools, preopens)?.unwrap_or_default(); - register_internal_tools(&mut tools, &exit_code, network); + register_internal_tools(&mut tools, &exit_code, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { @@ -1786,6 +1850,7 @@ impl Sandbox { tools: Option, preopens: &[Preopen], network: Option<&NetworkPolicy>, + listen_ports: Option<&ListenPorts>, ) -> Result { if !kernel_path.exists() { return Err(anyhow!("Kernel not found: {:?}", kernel_path)); @@ -1817,7 +1882,7 @@ impl Sandbox { let exit_code = Arc::new(AtomicI32::new(0)); let mut tools = build_tools(tools, preopens)?.unwrap_or_default(); - register_internal_tools(&mut tools, &exit_code, network); + register_internal_tools(&mut tools, &exit_code, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { @@ -1956,7 +2021,7 @@ impl Sandbox { /// a 2.5 GB snapshot — enough to double the whole `pyhl run` wall /// time on simple scripts. pub fn from_snapshot_file>(path: P) -> Result { - Self::from_snapshot_file_full(path, &[], None, None) + Self::from_snapshot_file_full(path, &[], None, None, None) } /// Load a previously-persisted snapshot and register a @@ -1971,7 +2036,7 @@ impl Sandbox { /// fixed at setup time because it lives in the snapshot's memory /// image. pub fn from_snapshot_file_with>(path: P, preopens: &[Preopen]) -> Result { - Self::from_snapshot_file_full(path, preopens, None, None) + Self::from_snapshot_file_full(path, preopens, None, None, None) } /// Load a snapshot with an initrd file re-mapped at the standard @@ -1983,18 +2048,31 @@ impl Sandbox { preopens: &[Preopen], initrd: I, ) -> Result { - Self::from_snapshot_file_full(path, preopens, Some(initrd.as_ref().to_path_buf()), None) + Self::from_snapshot_file_full( + path, + preopens, + Some(initrd.as_ref().to_path_buf()), + None, + None, + ) } - /// Load a snapshot with full configuration: preopens, initrd, and - /// network policy. + /// Load a snapshot with full configuration: preopens, initrd, + /// network policy, and listen-port allowlist. pub fn from_snapshot_file_configured>( path: P, preopens: &[Preopen], initrd: Option<&Path>, network: Option<&NetworkPolicy>, + listen_ports: Option<&ListenPorts>, ) -> Result { - Self::from_snapshot_file_full(path, preopens, initrd.map(|p| p.to_path_buf()), network) + Self::from_snapshot_file_full( + path, + preopens, + initrd.map(|p| p.to_path_buf()), + network, + listen_ports, + ) } fn from_snapshot_file_full>( @@ -2002,13 +2080,14 @@ impl Sandbox { preopens: &[Preopen], initrd: Option, network: Option<&NetworkPolicy>, + listen_ports: Option<&ListenPorts>, ) -> Result { let loaded = Snapshot::from_file_unchecked(path.as_ref())?; let arc = Arc::new(loaded); let exit_code = Arc::new(AtomicI32::new(0)); let mut tools = build_tools(None, preopens)?.unwrap_or_default(); - register_internal_tools(&mut tools, &exit_code, network); + register_internal_tools(&mut tools, &exit_code, network, listen_ports); let tools = Arc::new(tools); let tools_ref = tools.clone(); @@ -2046,7 +2125,7 @@ pub fn run_vm( app_args: &[String], config: VmConfig, ) -> Result<()> { - let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None)?; + let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None, None)?; Ok(()) } @@ -2066,6 +2145,7 @@ pub fn run_vm_with_tools( Some(tools), &[], None, + None, )?; Ok(()) } @@ -2079,7 +2159,16 @@ pub fn run_vm_with_preopens( config: VmConfig, preopens: &[Preopen], ) -> Result<()> { - let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, preopens, None)?; + let _ = Sandbox::evolve_inline( + kernel_path, + initrd, + app_args, + config, + None, + preopens, + None, + None, + )?; Ok(()) } @@ -2107,7 +2196,7 @@ pub fn run_vm_capture_output( // Phase 1: evolve — boots the kernel and takes a post-init snapshot. // No application output happens here. let mut sandbox = - Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None)?; + Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None, None)?; let setup_time = setup_start.elapsed(); // Redirect stderr to a temp file before the call phase @@ -2586,7 +2675,12 @@ mod tests { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); let bl = BlockList::from_hosts(&["1.2.3.4"]).unwrap(); - register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::BlockList(bl))); + register_internal_tools( + &mut tools, + &exit_code, + Some(&NetworkPolicy::BlockList(bl)), + None, + ); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -2597,7 +2691,7 @@ mod tests { fn net_tools_not_registered_without_policy() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, None); + register_internal_tools(&mut tools, &exit_code, None, None); let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -2608,10 +2702,90 @@ mod tests { fn net_tools_registered_with_allow_all() { let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); - register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll)); + register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll), None); + let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; + let resp = tools.dispatch(req); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.contains("\"fd\""), "net_socket should work: {s}"); + } + + // -- ListenPorts tests ----------------------------------------------------- + + #[test] + fn listen_ports_permits_listed_port() { + let lp = ListenPorts::from_ports([8080]); + assert!(lp.check(8080).is_ok()); + } + + #[test] + fn listen_ports_denies_unlisted_port() { + let lp = ListenPorts::from_ports([8080]); + let err = lp.check(9090).unwrap_err(); + assert!(err.to_string().contains("Permission denied"), "{err}"); + } + + #[test] + fn net_bind_denied_without_listen_ports() { + let mut tools = ToolRegistry::new(); + let exit_code = Arc::new(AtomicI32::new(0)); + register_internal_tools(&mut tools, &exit_code, Some(&NetworkPolicy::AllowAll), None); + // Create a socket first let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; let resp = tools.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); assert!(s.contains("\"fd\""), "net_socket should work: {s}"); + // Try to bind — should fail because no listen_ports + let req = br#"{"name":"net_bind","args":{"fd":0,"addr":"127.0.0.1","port":8080}}"#; + let resp = tools.dispatch(req); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.contains("\"error\""), "net_bind should be denied: {s}"); + assert!(s.contains("no --port"), "{s}"); + } + + #[test] + fn net_bind_allowed_with_matching_port() { + let mut tools = ToolRegistry::new(); + let exit_code = Arc::new(AtomicI32::new(0)); + let lp = ListenPorts::from_ports([8080]); + register_internal_tools( + &mut tools, + &exit_code, + Some(&NetworkPolicy::AllowAll), + Some(&lp), + ); + let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; + let resp = tools.dispatch(req); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.contains("\"fd\""), "net_socket should work: {s}"); + let v: serde_json::Value = serde_json::from_slice(&resp).unwrap(); + let fd = v["result"]["fd"].as_u64().unwrap(); + let req = + format!(r#"{{"name":"net_bind","args":{{"fd":{fd},"addr":"127.0.0.1","port":8080}}}}"#); + let resp = tools.dispatch(req.as_bytes()); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(!s.contains("\"error\""), "net_bind should succeed: {s}"); + } + + #[test] + fn net_bind_denied_with_wrong_port() { + let mut tools = ToolRegistry::new(); + let exit_code = Arc::new(AtomicI32::new(0)); + let lp = ListenPorts::from_ports([8080]); + register_internal_tools( + &mut tools, + &exit_code, + Some(&NetworkPolicy::AllowAll), + Some(&lp), + ); + let req = br#"{"name":"net_socket","args":{"family":2,"type":1}}"#; + let resp = tools.dispatch(req); + let v: serde_json::Value = serde_json::from_slice(&resp).unwrap(); + let fd = v["result"]["fd"].as_u64().unwrap(); + let req = + format!(r#"{{"name":"net_bind","args":{{"fd":{fd},"addr":"127.0.0.1","port":9090}}}}"#); + let resp = tools.dispatch(req.as_bytes()); + let s = std::str::from_utf8(&resp).unwrap(); + assert!(s.contains("\"error\""), "net_bind should be denied: {s}"); + assert!(s.contains("Permission denied"), "{s}"); } } diff --git a/host/src/main.rs b/host/src/main.rs index 7a8d653..f5437ce 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -8,7 +8,9 @@ use anyhow::Result; use clap::Parser; -use hyperlight_unikraft::{parse_memory, AllowList, BlockList, NetworkPolicy, Preopen, Sandbox}; +use hyperlight_unikraft::{ + parse_memory, AllowList, BlockList, ListenPorts, NetworkPolicy, Preopen, Sandbox, +}; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -82,6 +84,12 @@ struct Args { )] net_block: Vec, + /// Allow the guest to bind (listen) on the given port. Implies --net. + /// Without this flag, `net_bind` is rejected (outbound-only). + /// Repeatable: `--port 8080 --port 3000`. + #[arg(long, value_name = "PORT")] + port: Vec, + /// Run the application N additional times via snapshot/restore + call. /// The first run always happens. --repeat=2 means 3 total runs. #[arg(long, default_value = "0")] @@ -177,6 +185,7 @@ fn main() -> Result<()> { None => args.app_args.clone(), }; + let has_ports = !args.port.is_empty(); let network = if !args.net_allow.is_empty() { Some(NetworkPolicy::AllowList(AllowList::from_hosts( &args.net_allow, @@ -185,12 +194,18 @@ fn main() -> Result<()> { Some(NetworkPolicy::BlockList(BlockList::from_hosts( &args.net_block, )?)) - } else if args.net { + } else if args.net || has_ports { Some(NetworkPolicy::AllowAll) } else { None }; + let listen_ports = if has_ports { + Some(ListenPorts::from_ports(args.port.iter().copied())) + } else { + None + }; + let mut builder = Sandbox::builder(&args.kernel) .args(app_args) .heap_size(heap_size) @@ -204,6 +219,9 @@ fn main() -> Result<()> { if let Some(policy) = network { builder = builder.network(policy); } + if let Some(ports) = listen_ports { + builder = builder.listen_ports(ports); + } if args.enable_tools { builder = builder.tool("echo", Ok); } diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 7b8a077..c5199d8 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -28,10 +28,11 @@ //! source: pyhl::InstallSource::Ghcr, //! mounts: &[], //! network: None, +//! listen_ports: None, //! force: false, //! })?; //! -//! let mut rt = pyhl::Runtime::new(home, &[Preopen::new("./share", "/host")?], None)?; +//! let mut rt = pyhl::Runtime::new(home, &[Preopen::new("./share", "/host")?], None, None)?; //! rt.run_code("print('hello from rust')")?; //! rt.run_code("print('hermetic second call')")?; // fresh __main__ each time //! # Ok(()) @@ -92,6 +93,10 @@ pub struct InstallOptions<'a> { /// enables `net_*` tools with the given restrictions. pub network: Option<&'a crate::NetworkPolicy>, + /// Ports the guest may bind to for inbound connections. + /// `None` means outbound-only (when networking is enabled). + pub listen_ports: Option<&'a crate::ListenPorts>, + /// Overwrite an existing install. pub force: bool, } @@ -191,6 +196,9 @@ pub fn install(opts: &InstallOptions<'_>) -> Result { if let Some(policy) = opts.network { builder = builder.network(policy.clone()); } + if let Some(lp) = opts.listen_ports { + builder = builder.listen_ports(lp.clone()); + } let mut sbox = builder.build()?; sbox.restore()?; let _: () = sbox.call_named("run", "pass".to_string())?; @@ -242,11 +250,13 @@ impl Runtime { /// `{home}/snapshot.hls` and mmap-loads it. `mounts` specify host /// directories to expose under the guest paths that were baked in /// at `install` time. `network` enables guest networking with the - /// given policy (`None` = disabled). + /// given policy (`None` = disabled). `listen_ports` controls which + /// ports the guest may bind to (`None` = outbound-only). pub fn new( home: &Path, mounts: &[Preopen], network: Option<&crate::NetworkPolicy>, + listen_ports: Option<&crate::ListenPorts>, ) -> Result { default_surrogate_count(); let snap = home.join(SNAPSHOT_FILE); @@ -262,7 +272,13 @@ impl Runtime { } else { None }; - let sandbox = Sandbox::from_snapshot_file_configured(&snap, mounts, initrd_ref, network)?; + let sandbox = Sandbox::from_snapshot_file_configured( + &snap, + mounts, + initrd_ref, + network, + listen_ports, + )?; Ok(Self { sandbox, first_run: true, From 987faa1f3b0d6172fc7d284630b273a08b919e25 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 22:35:30 +0000 Subject: [PATCH 2/3] host: allow clippy::too_many_arguments on internal evolve helpers Signed-off-by: danbugs --- host/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/host/src/lib.rs b/host/src/lib.rs index ac2e082..eb13a37 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1807,6 +1807,7 @@ impl Sandbox { } /// Low-level: boot with an in-memory initrd buffer. Prefer the builder. + #[allow(clippy::too_many_arguments)] pub(crate) fn evolve_inline( kernel_path: &Path, initrd: Option<&[u8]>, @@ -1842,6 +1843,7 @@ impl Sandbox { } /// Low-level: boot with a zero-copy mapped initrd file. Prefer the builder. + #[allow(clippy::too_many_arguments)] pub(crate) fn evolve_mapped( kernel_path: &Path, initrd_path: Option<&Path>, From 1c303ff8c02fd4dfce8d6dac916e9fc7fcb7876a Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 22:42:48 +0000 Subject: [PATCH 3/3] ci: add port-allowlist test for networking-py Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 8 ++++++++ examples/networking-py/Dockerfile | 1 + examples/networking-py/echo_server_test.py | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 examples/networking-py/echo_server_test.py diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index eb3da35..a267e3a 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -237,6 +237,10 @@ jobs: memory: "512Mi" args: "--net -- /urllib_get.py" expect: "SUCCESS: urllib GET worked!" + - example: networking-py + memory: "512Mi" + args: "--port 8080 -- /echo_server_test.py" + expect: "SUCCESS: bind\\+listen on port 8080 allowed" steps: - uses: actions/checkout@v4 @@ -569,6 +573,10 @@ jobs: memory: "512Mi" args: "--net -- /urllib_get.py" expect: "SUCCESS: urllib GET worked!" + - example: networking-py + memory: "512Mi" + args: "--port 8080 -- /echo_server_test.py" + expect: "SUCCESS: bind\\+listen on port 8080 allowed" steps: - uses: actions/checkout@v4 diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile index fd24da1..c16ba5b 100644 --- a/examples/networking-py/Dockerfile +++ b/examples/networking-py/Dockerfile @@ -9,6 +9,7 @@ COPY http_get.py /http_get.py COPY echo_server.py /echo_server.py COPY urllib_get.py /urllib_get.py COPY https_test.py /https_test.py +COPY echo_server_test.py /echo_server_test.py # --- CPIO rootfs builder --- FROM alpine:3.20 AS cpio diff --git a/examples/networking-py/echo_server_test.py b/examples/networking-py/echo_server_test.py new file mode 100644 index 0000000..f1e762c --- /dev/null +++ b/examples/networking-py/echo_server_test.py @@ -0,0 +1,17 @@ +"""Test that --port allows bind+listen and no --port would reject it. + +This is a run-to-completion test for CI: it binds, listens, then exits. +The fact that bind+listen succeed (instead of raising OSError) proves +the --port allowlist is working. +""" +import socket + +HOST = "0.0.0.0" +PORT = 8080 + +srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind((HOST, PORT)) +srv.listen(1) +srv.close() +print("SUCCESS: bind+listen on port 8080 allowed")