From 21104756a1b562be060d525bcf860b22b9d68cdb Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 06:32:02 +0000 Subject: [PATCH 01/15] host: add host-proxied socket tools for networking Adds a SocketTable and net_* tool handlers (net_socket, net_connect, net_bind, net_listen, net_accept, net_send, net_sendto, net_recv, net_recvfrom, net_close, net_shutdown, net_setsockopt, net_getsockopt, net_getpeername, net_getsockname) using socket2 for cross-platform socket management. Registered automatically via register_net_tools. Signed-off-by: danbugs --- host/Cargo.toml | 1 + host/src/lib.rs | 327 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) diff --git a/host/Cargo.toml b/host/Cargo.toml index 8711d5b..d84fbd8 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -35,6 +35,7 @@ anyhow = "1" memmap2 = "0.9" serde_json = "1" base64 = "0.22" +socket2 = { version = "0.5", features = ["all"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["fs"] } diff --git a/host/src/lib.rs b/host/src/lib.rs index b3bdf1b..7cebd29 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -809,6 +809,333 @@ fn register_internal_tools(tools: &mut ToolRegistry, exit_code: &Arc) } Ok(serde_json::json!({})) }); + register_net_tools(tools); +} + +// --------------------------------------------------------------------------- +// Host-proxied networking (hostsock) +// --------------------------------------------------------------------------- + +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; +use std::net::SocketAddr; +use std::sync::Mutex; + +enum HostSocket { + Socket(Socket), +} + +struct SocketTable { + sockets: HashMap, + next_id: u32, +} + +impl SocketTable { + fn new() -> Self { + Self { + sockets: HashMap::new(), + next_id: 1, + } + } + + fn insert(&mut self, sock: HostSocket) -> u32 { + let id = self.next_id; + self.next_id += 1; + self.sockets.insert(id, sock); + id + } + + fn get(&self, fd: u32) -> Result<&HostSocket> { + self.sockets + .get(&fd) + .ok_or_else(|| anyhow!("bad_fd: {}", fd)) + } + + fn get_socket(&self, fd: u32) -> Result<&Socket> { + match self.get(fd)? { + HostSocket::Socket(s) => Ok(s), + } + } + + fn remove(&mut self, fd: u32) -> Result<()> { + self.sockets + .remove(&fd) + .map(|_| ()) + .ok_or_else(|| anyhow!("bad_fd: {}", fd)) + } +} + +fn parse_sockaddr(args: &serde_json::Value) -> Result { + let addr_str = args["addr"] + .as_str() + .ok_or_else(|| anyhow!("missing 'addr'"))?; + let port = args["port"].as_u64().unwrap_or(0) as u16; + let ip: std::net::IpAddr = addr_str.parse().map_err(|e| anyhow!("bad addr: {}", e))?; + Ok(SocketAddr::new(ip, port)) +} + +fn sockaddr_to_json(addr: SocketAddr) -> serde_json::Value { + serde_json::json!({ + "addr": addr.ip().to_string(), + "port": addr.port(), + }) +} + +fn register_net_tools(tools: &mut ToolRegistry) { + use base64::Engine; + use serde_json::json; + + let table = Arc::new(Mutex::new(SocketTable::new())); + + // net_socket + let t = table.clone(); + tools.register("net_socket", move |args| { + let family = args["family"].as_i64().unwrap_or(2) as i32; // AF_INET=2 + let sock_type = args["type"].as_i64().unwrap_or(1) as i32; // SOCK_STREAM=1 + let protocol = args["protocol"].as_i64().unwrap_or(0) as i32; + + let domain = match family { + 2 => Domain::IPV4, + 10 => Domain::IPV6, + _ => return Err(anyhow!("InvalidInput: unsupported family {}", family)), + }; + let stype = match sock_type { + 1 => Type::STREAM, + 2 => Type::DGRAM, + _ => return Err(anyhow!("InvalidInput: unsupported type {}", sock_type)), + }; + let proto = if protocol == 0 { + None + } else { + Some(Protocol::from(protocol)) + }; + let sock = Socket::new(domain, stype, proto)?; + let fd = t.lock().unwrap().insert(HostSocket::Socket(sock)); + Ok(json!({ "fd": fd })) + }); + + // net_connect + let t = table.clone(); + tools.register("net_connect", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let addr = parse_sockaddr(&args)?; + let sa: SockAddr = addr.into(); + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.connect(&sa)?; + Ok(json!({})) + }); + + // net_bind + let t = table.clone(); + 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)?; + let sa: SockAddr = addr.into(); + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.bind(&sa)?; + Ok(json!({})) + }); + + // net_listen + let t = table.clone(); + tools.register("net_listen", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let backlog = args["backlog"].as_i64().unwrap_or(128) as i32; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.listen(backlog)?; + Ok(json!({})) + }); + + // net_accept + let t = table.clone(); + tools.register("net_accept", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let (new_sock, peer) = { + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.accept()? + }; + let peer_addr: Option = peer.as_socket(); + let new_fd = t.lock().unwrap().insert(HostSocket::Socket(new_sock)); + let mut resp = json!({ "fd": new_fd }); + if let Some(pa) = peer_addr { + resp["addr"] = json!(pa.ip().to_string()); + resp["port"] = json!(pa.port()); + } + Ok(resp) + }); + + // net_send + let t = table.clone(); + tools.register("net_send", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let data_b64 = args["data"] + .as_str() + .ok_or_else(|| anyhow!("missing 'data'"))?; + let data = base64::engine::general_purpose::STANDARD + .decode(data_b64) + .map_err(|e| anyhow!("base64 decode: {}", e))?; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let sent = sock.send(&data)?; + Ok(json!({ "sent": sent })) + }); + + // net_sendto + let t = table.clone(); + tools.register("net_sendto", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let data_b64 = args["data"] + .as_str() + .ok_or_else(|| anyhow!("missing 'data'"))?; + let data = base64::engine::general_purpose::STANDARD + .decode(data_b64) + .map_err(|e| anyhow!("base64 decode: {}", e))?; + let addr = parse_sockaddr(&args)?; + let sa: SockAddr = addr.into(); + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let sent = sock.send_to(&data, &sa)?; + Ok(json!({ "sent": sent })) + }); + + // net_recv (alias for net_recvfrom with no addr returned for stream) + let t = table.clone(); + tools.register("net_recv", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let len = args["len"].as_u64().unwrap_or(4096) as usize; + let mut buf = vec![std::mem::MaybeUninit::uninit(); len.min(65536)]; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let n = sock.recv(&mut buf)?; + let data: Vec = buf[..n] + .iter() + .map(|b| unsafe { b.assume_init() }) + .collect(); + let encoded = base64::engine::general_purpose::STANDARD.encode(&data); + Ok(json!({ "data": encoded, "len": n })) + }); + + // net_recvfrom + let t = table.clone(); + tools.register("net_recvfrom", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let len = args["len"].as_u64().unwrap_or(4096) as usize; + let mut buf = vec![0u8; len.min(65536)]; + + let buf_init = + unsafe { &mut *(buf.as_mut_slice() as *mut [u8] as *mut [std::mem::MaybeUninit]) }; + + let (n, peer) = { + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.recv_from(buf_init)? + }; + buf.truncate(n); + let encoded = base64::engine::general_purpose::STANDARD.encode(&buf); + let mut resp = json!({ "data": encoded, "len": n }); + if let Some(pa) = peer.as_socket() { + resp["addr"] = json!(pa.ip().to_string()); + resp["port"] = json!(pa.port()); + } + Ok(resp) + }); + + // net_close + let t = table.clone(); + tools.register("net_close", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + t.lock().unwrap().remove(fd)?; + Ok(json!({})) + }); + + // net_shutdown + let t = table.clone(); + tools.register("net_shutdown", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let how = args["how"].as_i64().unwrap_or(2) as i32; + let shutdown = match how { + 0 => std::net::Shutdown::Read, + 1 => std::net::Shutdown::Write, + _ => std::net::Shutdown::Both, + }; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + sock.shutdown(shutdown)?; + Ok(json!({})) + }); + + // net_setsockopt + let t = table.clone(); + tools.register("net_setsockopt", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let level = args["level"].as_i64().unwrap_or(0) as i32; + let optname = args["optname"].as_i64().unwrap_or(0) as i32; + let value = args["value"].as_i64().unwrap_or(0) as i32; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + // SOL_SOCKET=1, SO_REUSEADDR=2 + if level == 1 && optname == 2 { + sock.set_reuse_address(value != 0)?; + } + // SOL_SOCKET=1, SO_KEEPALIVE=9 + if level == 1 && optname == 9 { + sock.set_keepalive(value != 0)?; + } + // IPPROTO_TCP=6, TCP_NODELAY=1 + if level == 6 && optname == 1 { + sock.set_nodelay(value != 0)?; + } + Ok(json!({})) + }); + + // net_getsockopt + let t = table.clone(); + tools.register("net_getsockopt", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let level = args["level"].as_i64().unwrap_or(0) as i32; + let optname = args["optname"].as_i64().unwrap_or(0) as i32; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let val: i32 = if level == 1 && optname == 2 { + sock.reuse_address()? as i32 + } else if level == 6 && optname == 1 { + sock.nodelay()? as i32 + } else { + 0 + }; + Ok(json!({ "value": val })) + }); + + // net_getpeername + let t = table.clone(); + tools.register("net_getpeername", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let peer = sock.peer_addr()?; + if let Some(addr) = peer.as_socket() { + Ok(sockaddr_to_json(addr)) + } else { + Ok(json!({ "addr": "0.0.0.0", "port": 0 })) + } + }); + + // net_getsockname + let t = table.clone(); + tools.register("net_getsockname", move |args| { + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let tbl = t.lock().unwrap(); + let sock = tbl.get_socket(fd)?; + let local = sock.local_addr()?; + if let Some(addr) = local.as_socket() { + Ok(sockaddr_to_json(addr)) + } else { + Ok(json!({ "addr": "0.0.0.0", "port": 0 })) + } + }); } /// Routes incoming fs_* tool calls to the matching `FsSandbox` by From 88d888d8bdbd6b1247edaf481dc0463502dc7e19 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 06:33:05 +0000 Subject: [PATCH 02/15] examples: add networking-py example with HTTP GET and echo server Python networking test using host-proxied sockets (hostsock). Includes HTTP GET client (raw sockets, no DNS needed) and a TCP echo server. kraft.yaml enables LIBPOSIX_SOCKET + LIBHOSTSOCK and points to danbugs/unikraft:networking for the hostsock driver. Signed-off-by: danbugs --- examples/networking-py/Dockerfile | 22 ++++++++ examples/networking-py/Justfile | 51 ++++++++++++++++++ examples/networking-py/echo_server.py | 30 +++++++++++ examples/networking-py/http_get.py | 40 ++++++++++++++ examples/networking-py/kraft.yaml | 78 +++++++++++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 examples/networking-py/Dockerfile create mode 100644 examples/networking-py/Justfile create mode 100644 examples/networking-py/echo_server.py create mode 100644 examples/networking-py/http_get.py create mode 100644 examples/networking-py/kraft.yaml diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile new file mode 100644 index 0000000..d1833e1 --- /dev/null +++ b/examples/networking-py/Dockerfile @@ -0,0 +1,22 @@ +# Networking Python example on Hyperlight +# +# Extends the Python base runtime with networking test scripts +# and DNS configuration. + +ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest +FROM ${BASE} AS rootfs +COPY http_get.py /http_get.py +COPY echo_server.py /echo_server.py + +# DNS configuration for glibc's getaddrinfo +RUN mkdir -p /etc && \ + echo "nameserver 8.8.8.8" > /etc/resolv.conf && \ + echo "nameserver 8.8.4.4" >> /etc/resolv.conf && \ + echo "hosts: files dns" > /etc/nsswitch.conf && \ + echo "127.0.0.1 localhost" > /etc/hosts + +# --- CPIO rootfs builder --- +FROM alpine:3.20 AS cpio +RUN apk add --no-cache cpio findutils +COPY --from=rootfs / /rootfs/ +RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/networking-py/Justfile b/examples/networking-py/Justfile new file mode 100644 index 0000000..4c12588 --- /dev/null +++ b/examples/networking-py/Justfile @@ -0,0 +1,51 @@ +# networking-py on Hyperlight +# +# just rootfs - Build rootfs via Docker (cross-platform) +# just build - Build kernel +# just run-get - Run HTTP GET test +# just run-echo - Run echo server +# just clean - Remove build artifacts + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] +export DOCKER_BUILDKIT := "0" + +kernel := ".unikraft/build/networking-py-hyperlight_hyperlight-x86_64" +initrd := "initrd.cpio" +memory := "512Mi" +image := "networking-py-hyperlight" + +# Run HTTP GET test +run-get: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /http_get.py + +# Run echo server +run-echo: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /echo_server.py + +# Build rootfs via Docker (cross-platform) +rootfs: + docker build --platform linux/amd64 --target cpio -t {{image}}-cpio . + - docker rm -f {{image}}-tmp + docker create --name {{image}}-tmp {{image}}-cpio /bin/true + docker cp {{image}}-tmp:/output.cpio ./{{initrd}} + docker rm -f {{image}}-tmp + +# Build kernel via kraft (Linux) +[unix] +build: + -kraft-hyperlight build --plat hyperlight --arch x86_64 + +# Pull pre-built kernel from GHCR (Windows — no kernel published yet) +[windows] +build: + Write-Host "No pre-built kernel for networking-py yet. Build on Linux first." + +# Clean build artifacts +[unix] +clean: + rm -rf .unikraft {{initrd}} + +[windows] +clean: + if (Test-Path .unikraft) { Remove-Item -Recurse -Force .unikraft } + if (Test-Path {{initrd}}) { Remove-Item -Force {{initrd}} } diff --git a/examples/networking-py/echo_server.py b/examples/networking-py/echo_server.py new file mode 100644 index 0000000..a972b9e --- /dev/null +++ b/examples/networking-py/echo_server.py @@ -0,0 +1,30 @@ +"""Simple TCP echo server for testing host-proxied networking. + +Binds to 0.0.0.0:8080 and echoes back whatever a client sends, +prefixed with "ECHO: ". Exits after the first client disconnects. +""" +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) +print(f"Listening on {HOST}:{PORT}...") + +conn, addr = srv.accept() +print(f"Connection from {addr}") + +while True: + data = conn.recv(1024) + if not data: + break + reply = b"ECHO: " + data + conn.sendall(reply) + print(f"Echoed {len(data)} bytes") + +conn.close() +srv.close() +print("Server done.") diff --git a/examples/networking-py/http_get.py b/examples/networking-py/http_get.py new file mode 100644 index 0000000..e7ba4f8 --- /dev/null +++ b/examples/networking-py/http_get.py @@ -0,0 +1,40 @@ +"""Simple HTTP GET test for Hyperlight networking. + +Uses raw sockets to avoid DNS dependency for initial testing. +Connects to httpbin.org (54.144.44.106) and issues a GET /get. +""" +import socket +import sys + +HOST = "54.144.44.106" +PORT = 80 +PATH = "/get" + +print(f"Connecting to {HOST}:{PORT}...") +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.connect((HOST, PORT)) +print("Connected!") + +request = f"GET {PATH} HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n" +sock.sendall(request.encode()) +print("Request sent, waiting for response...") + +response = b"" +while True: + chunk = sock.recv(4096) + if not chunk: + break + response += chunk + +sock.close() + +text = response.decode("utf-8", errors="replace") +lines = text.split("\r\n") +print(f"Status: {lines[0]}") +print(f"Body length: {len(text)} bytes") + +if "200 OK" in lines[0]: + print("SUCCESS: HTTP GET worked!") +else: + print(f"UNEXPECTED: {lines[0]}") + sys.exit(1) diff --git a/examples/networking-py/kraft.yaml b/examples/networking-py/kraft.yaml new file mode 100644 index 0000000..036ab34 --- /dev/null +++ b/examples/networking-py/kraft.yaml @@ -0,0 +1,78 @@ +specification: '0.6' +name: networking-py-hyperlight + +unikraft: + source: https://github.com/danbugs/unikraft.git + version: networking + kconfig: + # Platform + CONFIG_PLAT_HYPERLIGHT: 'y' + CONFIG_PAGING: 'n' + CONFIG_LIBUKVMEM: 'n' + CONFIG_LIBUKINTCTLR_HYPERLIGHT: 'y' + CONFIG_HYPERLIGHT_MAX_GUEST_LOG_LEVEL: 4 + + # Suppress all kernel logging + CONFIG_LIBUKPRINT_KLVL_CRIT: 'y' + CONFIG_LIBUKPRINT_PRINT_TIME: 'n' + CONFIG_LIBUKPRINT_PRINT_SRCNAME: 'n' + CONFIG_LIBUKBOOT_BANNER_NONE: 'y' + + # Size optimization + CONFIG_OPTIMIZE_SIZE: 'y' + CONFIG_OPTIMIZE_PIE: 'y' + + # VFS and initrd support - cpiovfs for zero-copy mount + CONFIG_LIBVFSCORE: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI_CUSTOM: 'y' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_MP: '/' + CONFIG_LIBVFSCORE_AUTOMOUNT_CI0_DRIVER: 'cpiovfs' + CONFIG_LIBCPIOVFS: 'y' + CONFIG_LIBUKCPIO: 'y' + + # ELF loader - execute Python binary + CONFIG_APPELFLOADER: 'y' + CONFIG_APPELFLOADER_VFSEXEC: 'y' + CONFIG_APPELFLOADER_CUSTOMAPPNAME: 'n' + CONFIG_APPELFLOADER_VFSEXEC_ENVPATH: 'n' + CONFIG_APPELFLOADER_VFSEXEC_PATH: '/usr/local/bin/python3' + CONFIG_APPELFLOADER_VFSEXEC_EXECBIT: 'n' + CONFIG_LIBPOSIX_ENVIRON_ENVP0: 'PATH=/usr/local/bin:/usr/bin:/bin' + CONFIG_LIBPOSIX_ENVIRON_ENVP1: 'LD_LIBRARY_PATH=/usr/local/lib' + CONFIG_LIBELF: 'y' + + # Random number support (required for Python) + CONFIG_LIBUKRANDOM_CMDLINE_SEED: 'y' + CONFIG_LIBUKRANDOM_GETRANDOM: 'y' + + # Threading support + CONFIG_LIBUKBOOT_MAINTHREAD: 'y' + CONFIG_LIBPOSIX_PROCESS_ARCH_PRCTL: 'y' + CONFIG_LIBPOSIX_PROCESS_MULTITHREADING: 'y' + CONFIG_LIBPOSIX_FUTEX: 'y' + + # MPI + mmap + CONFIG_LIBUKMPI: 'y' + CONFIG_LIBUKMMAP: 'y' + + # Networking: host-proxied sockets + CONFIG_LIBPOSIX_SOCKET: 'y' + CONFIG_LIBHOSTSOCK: 'y' + + # FD infrastructure (required by posix-socket) + CONFIG_LIBPOSIX_FDIO: 'y' + CONFIG_LIBPOSIX_FDTAB: 'y' + CONFIG_LIBUKFILE: 'y' + +libraries: + app-elfloader: + source: https://github.com/unikraft/app-elfloader.git + version: plat-hyperlight + libelf: + source: https://github.com/unikraft/lib-libelf.git + version: staging + +targets: + - architecture: x86_64 + platform: hyperlight From 2a870f8520c197e0d215f09f1d6fbcfe8d47c39d Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 06:52:13 +0000 Subject: [PATCH 03/15] networking-py: fix build deps, Dockerfile, and test target Signed-off-by: danbugs --- examples/networking-py/Dockerfile | 16 ++--- examples/networking-py/http_get.py | 8 +-- examples/networking-py/kraft.yaml | 5 ++ host/Cargo.lock | 96 ++++++++++++++++++++++++++++-- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile index d1833e1..e529770 100644 --- a/examples/networking-py/Dockerfile +++ b/examples/networking-py/Dockerfile @@ -8,15 +8,17 @@ FROM ${BASE} AS rootfs COPY http_get.py /http_get.py COPY echo_server.py /echo_server.py -# DNS configuration for glibc's getaddrinfo -RUN mkdir -p /etc && \ - echo "nameserver 8.8.8.8" > /etc/resolv.conf && \ - echo "nameserver 8.8.4.4" >> /etc/resolv.conf && \ - echo "hosts: files dns" > /etc/nsswitch.conf && \ - echo "127.0.0.1 localhost" > /etc/hosts - # --- CPIO rootfs builder --- FROM alpine:3.20 AS cpio RUN apk add --no-cache cpio findutils COPY --from=rootfs / /rootfs/ + +# DNS configuration for glibc's getaddrinfo (added in the cpio stage +# because the python-base image has no shell) +RUN mkdir -p /rootfs/etc && \ + echo "nameserver 8.8.8.8" > /rootfs/etc/resolv.conf && \ + echo "nameserver 8.8.4.4" >> /rootfs/etc/resolv.conf && \ + echo "hosts: files dns" > /rootfs/etc/nsswitch.conf && \ + echo "127.0.0.1 localhost" > /rootfs/etc/hosts + RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/networking-py/http_get.py b/examples/networking-py/http_get.py index e7ba4f8..c023455 100644 --- a/examples/networking-py/http_get.py +++ b/examples/networking-py/http_get.py @@ -1,21 +1,21 @@ """Simple HTTP GET test for Hyperlight networking. Uses raw sockets to avoid DNS dependency for initial testing. -Connects to httpbin.org (54.144.44.106) and issues a GET /get. +Connects to example.com and issues a GET /. """ import socket import sys -HOST = "54.144.44.106" +HOST = "172.66.147.243" PORT = 80 -PATH = "/get" +PATH = "/" print(f"Connecting to {HOST}:{PORT}...") sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((HOST, PORT)) print("Connected!") -request = f"GET {PATH} HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n" +request = f"GET {PATH} HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" sock.sendall(request.encode()) print("Request sent, waiting for response...") diff --git a/examples/networking-py/kraft.yaml b/examples/networking-py/kraft.yaml index 036ab34..cee28e6 100644 --- a/examples/networking-py/kraft.yaml +++ b/examples/networking-py/kraft.yaml @@ -56,6 +56,11 @@ unikraft: CONFIG_LIBUKMPI: 'y' CONFIG_LIBUKMMAP: 'y' + # devfs + /dev/hcall (required for host-proxied networking) + CONFIG_LIBDEVFS: 'y' + CONFIG_LIBDEVFS_AUTOMOUNT: 'y' + CONFIG_HYPERLIGHT_HCALL: 'y' + # Networking: host-proxied sockets CONFIG_LIBPOSIX_SOCKET: 'y' CONFIG_LIBHOSTSOCK: 'y' diff --git a/host/Cargo.lock b/host/Cargo.lock index ed10cd1..b1f4517 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -356,7 +356,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -563,7 +563,7 @@ dependencies = [ "vmm-sys-util", "windows", "windows-result", - "windows-sys", + "windows-sys 0.61.2", "windows-version", ] @@ -579,7 +579,8 @@ dependencies = [ "memmap2", "nix", "serde_json", - "windows-sys", + "socket2", + "windows-sys 0.61.2", ] [[package]] @@ -1270,6 +1271,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "spin" version = "0.10.0" @@ -1612,7 +1623,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1722,6 +1733,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1731,6 +1751,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -1749,6 +1785,54 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" From a7e32eac98fe7a943c987905cc35f420be32aa86 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 13:30:17 +0000 Subject: [PATCH 04/15] networking-py: add urllib high-level HTTP test Signed-off-by: danbugs --- examples/networking-py/Dockerfile | 1 + examples/networking-py/Justfile | 6 +++++- examples/networking-py/urllib_get.py | 27 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 examples/networking-py/urllib_get.py diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile index e529770..b7fdf80 100644 --- a/examples/networking-py/Dockerfile +++ b/examples/networking-py/Dockerfile @@ -7,6 +7,7 @@ ARG BASE=ghcr.io/hyperlight-dev/hyperlight-unikraft/python-base:latest FROM ${BASE} AS rootfs COPY http_get.py /http_get.py COPY echo_server.py /echo_server.py +COPY urllib_get.py /urllib_get.py # --- CPIO rootfs builder --- FROM alpine:3.20 AS cpio diff --git a/examples/networking-py/Justfile b/examples/networking-py/Justfile index 4c12588..e9605ce 100644 --- a/examples/networking-py/Justfile +++ b/examples/networking-py/Justfile @@ -14,10 +14,14 @@ initrd := "initrd.cpio" memory := "512Mi" image := "networking-py-hyperlight" -# Run HTTP GET test +# Run HTTP GET test (raw sockets) run-get: hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /http_get.py +# Run HTTP GET test (urllib — high-level stdlib) +run-urllib: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /urllib_get.py + # Run echo server run-echo: hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /echo_server.py diff --git a/examples/networking-py/urllib_get.py b/examples/networking-py/urllib_get.py new file mode 100644 index 0000000..07ab556 --- /dev/null +++ b/examples/networking-py/urllib_get.py @@ -0,0 +1,27 @@ +"""High-level HTTP GET using urllib (Python stdlib). + +Demonstrates that Python's standard urllib.request module works +over the host-proxied socket layer — no raw sockets needed. + +Uses a raw IP with Host header to avoid DNS dependency (glibc's +getaddrinfo doesn't yet work over host-proxied UDP sockets). +""" +import urllib.request +import sys + +URL = "http://172.66.147.243/" + +print(f"Fetching {URL} ...") +req = urllib.request.Request(URL, headers={"Host": "example.com"}) +try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read().decode("utf-8", errors="replace") + print(f"Status: {resp.status}") + print(f"Body length: {len(body)} bytes") + if "Example Domain" in body: + print("SUCCESS: urllib GET worked!") + else: + print("WARNING: unexpected body content") +except Exception as e: + print(f"FAILED: {e}") + sys.exit(1) From 006ce5ed3f7dd712d3995953f90d8668597d875f Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 15:14:21 +0000 Subject: [PATCH 05/15] host: include address family in sockaddr JSON responses Signed-off-by: danbugs --- host/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/host/src/lib.rs b/host/src/lib.rs index 7cebd29..f0d56e6 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -874,7 +874,12 @@ fn parse_sockaddr(args: &serde_json::Value) -> Result { } fn sockaddr_to_json(addr: SocketAddr) -> serde_json::Value { + let family: i32 = match addr { + SocketAddr::V4(_) => 2, + SocketAddr::V6(_) => 10, + }; serde_json::json!({ + "family": family, "addr": addr.ip().to_string(), "port": addr.port(), }) From 48720df28263d3418dea7fe15ef48104d0ed0eec Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 15:14:24 +0000 Subject: [PATCH 06/15] networking-py: use DNS hostname in urllib_get.py Signed-off-by: danbugs --- examples/networking-py/urllib_get.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/networking-py/urllib_get.py b/examples/networking-py/urllib_get.py index 07ab556..167fb82 100644 --- a/examples/networking-py/urllib_get.py +++ b/examples/networking-py/urllib_get.py @@ -1,20 +1,16 @@ """High-level HTTP GET using urllib (Python stdlib). Demonstrates that Python's standard urllib.request module works -over the host-proxied socket layer — no raw sockets needed. - -Uses a raw IP with Host header to avoid DNS dependency (glibc's -getaddrinfo doesn't yet work over host-proxied UDP sockets). +over the host-proxied socket layer with full DNS resolution. """ import urllib.request import sys -URL = "http://172.66.147.243/" +URL = "http://example.com/" print(f"Fetching {URL} ...") -req = urllib.request.Request(URL, headers={"Host": "example.com"}) try: - with urllib.request.urlopen(req, timeout=10) as resp: + with urllib.request.urlopen(URL, timeout=10) as resp: body = resp.read().decode("utf-8", errors="replace") print(f"Status: {resp.status}") print(f"Body length: {len(body)} bytes") From b3103fa5e3c6e59a054dce222d49220b02cda881 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 16:15:45 +0000 Subject: [PATCH 07/15] host: return SOCK_STREAM for getsockopt(SO_TYPE) Signed-off-by: danbugs --- host/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index f0d56e6..7ef0d33 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1104,7 +1104,10 @@ fn register_net_tools(tools: &mut ToolRegistry) { let optname = args["optname"].as_i64().unwrap_or(0) as i32; let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; - let val: i32 = if level == 1 && optname == 2 { + let val: i32 = if level == 1 && optname == 3 { + // SOL_SOCKET + SO_TYPE — all our sockets are SOCK_STREAM + 1 + } else if level == 1 && optname == 2 { sock.reuse_address()? as i32 } else if level == 6 && optname == 1 { sock.nodelay()? as i32 From 2c331a92b068b34ef4e83de81f383cd6f0e8d707 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 16:15:49 +0000 Subject: [PATCH 08/15] networking-py: add HTTPS test and CA certificates Signed-off-by: danbugs --- examples/networking-py/Dockerfile | 8 +++++++- examples/networking-py/Justfile | 4 ++++ examples/networking-py/https_test.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 examples/networking-py/https_test.py diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile index b7fdf80..fd24da1 100644 --- a/examples/networking-py/Dockerfile +++ b/examples/networking-py/Dockerfile @@ -8,10 +8,11 @@ FROM ${BASE} AS rootfs 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 # --- CPIO rootfs builder --- FROM alpine:3.20 AS cpio -RUN apk add --no-cache cpio findutils +RUN apk add --no-cache cpio findutils ca-certificates COPY --from=rootfs / /rootfs/ # DNS configuration for glibc's getaddrinfo (added in the cpio stage @@ -22,4 +23,9 @@ RUN mkdir -p /rootfs/etc && \ echo "hosts: files dns" > /rootfs/etc/nsswitch.conf && \ echo "127.0.0.1 localhost" > /rootfs/etc/hosts +# CA certificates for TLS +RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/usr/lib/ssl && \ + cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ && \ + ln -sf /etc/ssl/certs/ca-certificates.crt /rootfs/usr/lib/ssl/cert.pem + RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/networking-py/Justfile b/examples/networking-py/Justfile index e9605ce..10e853f 100644 --- a/examples/networking-py/Justfile +++ b/examples/networking-py/Justfile @@ -22,6 +22,10 @@ run-get: run-urllib: hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /urllib_get.py +# Run HTTPS (TLS) test +run-https: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /https_test.py + # Run echo server run-echo: hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /echo_server.py diff --git a/examples/networking-py/https_test.py b/examples/networking-py/https_test.py new file mode 100644 index 0000000..b49ae9b --- /dev/null +++ b/examples/networking-py/https_test.py @@ -0,0 +1,11 @@ +import urllib.request +import sys + +print("Testing HTTPS (TLS) through proxied sockets...") +try: + r = urllib.request.urlopen('https://api.github.com', timeout=10) + print(f"Status: {r.status}") + print("SUCCESS: HTTPS works!") +except Exception as e: + print(f"FAILED: {e}") + sys.exit(1) From dfa9fb842d3e73f9db342bef9aeccf5395f35474 Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 17:04:46 +0000 Subject: [PATCH 09/15] host: add NetworkPolicy with opt-in networking and allowlist support Signed-off-by: danbugs --- host/examples/pyhl_as_library.rs | 2 +- host/src/lib.rs | 244 ++++++++++++++++++++++++++++--- host/src/pyhl.rs | 29 +++- 3 files changed, 248 insertions(+), 27 deletions(-) diff --git a/host/examples/pyhl_as_library.rs b/host/examples/pyhl_as_library.rs index ff20d7b..578ddfb 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)?; + let mut rt = pyhl::Runtime::new(&home, mounts, None)?; eprintln!("-- first run (hermetic from loaded snapshot) --"); let t1 = rt.run_code(&code)?; diff --git a/host/src/lib.rs b/host/src/lib.rs index 7ef0d33..9d321ba 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -65,7 +65,8 @@ use hyperlight_host::sandbox::snapshot::Snapshot; use hyperlight_host::sandbox::uninitialized::GuestEnvironment; use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Arc; @@ -153,10 +154,105 @@ impl Preopen { } } -// Guest VA for the initrd mapped via map_file_cow. -// Computed dynamically in new_with_file_initrd to be after the -// primary shared memory region, page-aligned. -// Falls back to 2 GiB if the sandbox config doesn't have heap info. +// --------------------------------------------------------------------------- +// Network policy +// --------------------------------------------------------------------------- + +/// Controls which network destinations a guest sandbox can reach. +/// +/// By default, networking is **disabled** (no `net_*` tools are registered). +/// Callers must opt in via [`SandboxBuilder::network`] or the `--net` CLI flag. +#[derive(Clone, Debug)] +pub enum NetworkPolicy { + /// All outbound connections are allowed (no filtering). + AllowAll, + /// Only connections to the listed destinations are permitted. + AllowList(AllowList), +} + +/// A set of allowed network destinations. +/// +/// Stores both literal IPs and hostnames. At check time, hostnames are +/// re-resolved so the policy tracks DNS changes (CDN rotation, etc.). +#[derive(Clone, Debug)] +pub struct AllowList { + allowed_ips: HashSet, + hostnames: Vec, +} + +impl AllowList { + /// Build an allowlist from a mixed set of hostnames and IP literals. + /// + /// Hostnames are verified to be resolvable at construction time + /// (fail-closed). At check time they are re-resolved so CDN/anycast + /// rotation doesn't cause false denials. + pub fn from_hosts(entries: &[impl AsRef]) -> Result { + use std::net::ToSocketAddrs; + let mut allowed_ips = HashSet::new(); + let mut hostnames = Vec::new(); + for entry in entries { + let entry = entry.as_ref(); + if let Ok(ip) = entry.parse::() { + allowed_ips.insert(ip); + } else { + let addrs = (entry, 0u16) + .to_socket_addrs() + .map_err(|e| anyhow!("resolve {:?}: {}", entry, e))?; + let mut found = false; + for sa in addrs { + allowed_ips.insert(sa.ip()); + found = true; + } + if !found { + return Err(anyhow!("hostname {:?} resolved to zero addresses", entry)); + } + hostnames.push(entry.to_string()); + } + } + Ok(Self { + allowed_ips, + hostnames, + }) + } + + fn is_allowed(&self, ip: &IpAddr) -> bool { + if self.allowed_ips.contains(ip) { + return true; + } + // Re-resolve hostnames to catch CDN/anycast IP rotation. + use std::net::ToSocketAddrs; + for host in &self.hostnames { + if let Ok(addrs) = (host.as_str(), 0u16).to_socket_addrs() { + for sa in addrs { + if &sa.ip() == ip { + return true; + } + } + } + } + false + } +} + +impl NetworkPolicy { + fn check(&self, addr: &std::net::SocketAddr) -> Result<()> { + match self { + NetworkPolicy::AllowAll => Ok(()), + NetworkPolicy::AllowList(al) => { + // Allow DNS (port 53) — hostname-based allowlists need + // the guest to reach DNS servers for resolution. + if addr.port() == 53 || al.is_allowed(&addr.ip()) { + Ok(()) + } else { + Err(anyhow!( + "network policy denies connection to {}", + addr + )) + } + } + } + } +} // --------------------------------------------------------------------------- // Configuration @@ -795,7 +891,13 @@ fn build_tools( /// Register internal tools (`__hl_exit`, `__hl_sleep`) on a tool registry. /// These are plumbing used by the guest driver (`hl_pydriver.c`) and are /// always present regardless of user-supplied tools or preopens. -fn register_internal_tools(tools: &mut ToolRegistry, exit_code: &Arc) { +/// +/// Networking tools are only registered when a [`NetworkPolicy`] is provided. +fn register_internal_tools( + tools: &mut ToolRegistry, + exit_code: &Arc, + network: Option<&NetworkPolicy>, +) { let ec = exit_code.clone(); tools.register("__hl_exit", move |args| { let code = args["code"].as_i64().unwrap_or(1) as i32; @@ -809,7 +911,9 @@ fn register_internal_tools(tools: &mut ToolRegistry, exit_code: &Arc) } Ok(serde_json::json!({})) }); - register_net_tools(tools); + if let Some(policy) = network { + register_net_tools(tools, policy); + } } // --------------------------------------------------------------------------- @@ -885,11 +989,12 @@ fn sockaddr_to_json(addr: SocketAddr) -> serde_json::Value { }) } -fn register_net_tools(tools: &mut ToolRegistry) { +fn register_net_tools(tools: &mut ToolRegistry, policy: &NetworkPolicy) { use base64::Engine; use serde_json::json; let table = Arc::new(Mutex::new(SocketTable::new())); + let policy = Arc::new(policy.clone()); // net_socket let t = table.clone(); @@ -920,9 +1025,11 @@ fn register_net_tools(tools: &mut ToolRegistry) { // net_connect let t = table.clone(); + let pol = policy.clone(); tools.register("net_connect", move |args| { let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; let addr = parse_sockaddr(&args)?; + pol.check(&addr)?; let sa: SockAddr = addr.into(); let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; @@ -990,6 +1097,7 @@ fn register_net_tools(tools: &mut ToolRegistry) { // net_sendto let t = table.clone(); + let pol = policy.clone(); tools.register("net_sendto", move |args| { let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; let data_b64 = args["data"] @@ -999,6 +1107,7 @@ fn register_net_tools(tools: &mut ToolRegistry) { .decode(data_b64) .map_err(|e| anyhow!("base64 decode: {}", e))?; let addr = parse_sockaddr(&args)?; + pol.check(&addr)?; let sa: SockAddr = addr.into(); let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; @@ -1427,6 +1536,7 @@ pub struct SandboxBuilder { heap_size: Option, stack_size: Option, preopens: Vec, + network: Option, tools: ToolRegistry, has_tools: bool, } @@ -1482,6 +1592,15 @@ impl SandboxBuilder { self } + /// Enable guest networking with the given policy. + /// + /// Without this call, no `net_*` tools are registered and the guest + /// has no network access. + pub fn network(mut self, policy: NetworkPolicy) -> Self { + self.network = Some(policy); + self + } + /// Register a host function callable from the guest via `__dispatch`. pub fn tool(mut self, name: &str, handler: F) -> Self where @@ -1503,6 +1622,7 @@ impl SandboxBuilder { } else { None }; + let net = self.network.as_ref(); match self.initrd { Some(InitrdSource::File(path)) => Sandbox::evolve_mapped( &self.kernel, @@ -1511,6 +1631,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), Some(InitrdSource::Bytes(bytes)) => Sandbox::evolve_inline( &self.kernel, @@ -1519,6 +1640,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), None => Sandbox::evolve_mapped( &self.kernel, @@ -1527,6 +1649,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), } } @@ -1543,6 +1666,7 @@ impl Sandbox { heap_size: None, stack_size: None, preopens: Vec::new(), + network: None, tools: ToolRegistry::new(), has_tools: false, } @@ -1556,6 +1680,7 @@ impl Sandbox { config: VmConfig, tools: Option, preopens: &[Preopen], + network: Option<&NetworkPolicy>, ) -> Result { if !kernel_path.exists() { return Err(anyhow!("Kernel not found: {:?}", kernel_path)); @@ -1571,7 +1696,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); + register_internal_tools(&mut tools, &exit_code, network); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { @@ -1589,6 +1714,7 @@ impl Sandbox { config: VmConfig, tools: Option, preopens: &[Preopen], + network: Option<&NetworkPolicy>, ) -> Result { if !kernel_path.exists() { return Err(anyhow!("Kernel not found: {:?}", kernel_path)); @@ -1620,7 +1746,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); + register_internal_tools(&mut tools, &exit_code, network); let tools = Arc::new(tools); let tools_ref = tools.clone(); usbox.register_host_function("__dispatch", move |payload: Vec| -> Vec { @@ -1759,7 +1885,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) + Self::from_snapshot_file_full(path, &[], None, None) } /// Load a previously-persisted snapshot and register a @@ -1774,7 +1900,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) + Self::from_snapshot_file_full(path, preopens, None, None) } /// Load a snapshot with an initrd file re-mapped at the standard @@ -1786,20 +1912,37 @@ impl Sandbox { preopens: &[Preopen], initrd: I, ) -> Result { - Self::from_snapshot_file_full(path, preopens, Some(initrd.as_ref().to_path_buf())) + Self::from_snapshot_file_full(path, preopens, Some(initrd.as_ref().to_path_buf()), None) + } + + /// Load a snapshot with full configuration: preopens, initrd, and + /// network policy. + pub fn from_snapshot_file_configured>( + path: P, + preopens: &[Preopen], + initrd: Option<&Path>, + network: Option<&NetworkPolicy>, + ) -> Result { + Self::from_snapshot_file_full( + path, + preopens, + initrd.map(|p| p.to_path_buf()), + network, + ) } fn from_snapshot_file_full>( path: P, preopens: &[Preopen], initrd: Option, + network: Option<&NetworkPolicy>, ) -> 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); + register_internal_tools(&mut tools, &exit_code, network); let tools = Arc::new(tools); let tools_ref = tools.clone(); @@ -1837,7 +1980,7 @@ pub fn run_vm( app_args: &[String], config: VmConfig, ) -> Result<()> { - let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[])?; + let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None)?; Ok(()) } @@ -1849,7 +1992,8 @@ pub fn run_vm_with_tools( config: VmConfig, tools: ToolRegistry, ) -> Result<()> { - let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, Some(tools), &[])?; + let _ = + Sandbox::evolve_inline(kernel_path, initrd, app_args, config, Some(tools), &[], None)?; Ok(()) } @@ -1862,7 +2006,7 @@ pub fn run_vm_with_preopens( config: VmConfig, preopens: &[Preopen], ) -> Result<()> { - let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, preopens)?; + let _ = Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, preopens, None)?; Ok(()) } @@ -1889,7 +2033,8 @@ 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, &[])?; + let mut sandbox = + Sandbox::evolve_inline(kernel_path, initrd, app_args, config, None, &[], None)?; let setup_time = setup_start.elapsed(); // Redirect stderr to a temp file before the call phase @@ -2283,4 +2428,67 @@ mod tests { let s = std::str::from_utf8(&resp).unwrap(); assert!(s.contains("\"text\":\"hi\""), "{s}"); } + + // -- NetworkPolicy tests -------------------------------------------------- + + #[test] + fn network_policy_allow_all_permits_any() { + let policy = NetworkPolicy::AllowAll; + let addr: std::net::SocketAddr = "1.2.3.4:443".parse().unwrap(); + assert!(policy.check(&addr).is_ok()); + } + + #[test] + fn network_policy_allowlist_permits_listed_ip() { + let al = AllowList::from_hosts(&["1.2.3.4"]).unwrap(); + let policy = NetworkPolicy::AllowList(al); + let addr: std::net::SocketAddr = "1.2.3.4:443".parse().unwrap(); + assert!(policy.check(&addr).is_ok()); + } + + #[test] + fn network_policy_allowlist_denies_unlisted_ip() { + let al = AllowList::from_hosts(&["1.2.3.4"]).unwrap(); + let policy = NetworkPolicy::AllowList(al); + let addr: std::net::SocketAddr = "5.6.7.8:80".parse().unwrap(); + let err = policy.check(&addr).unwrap_err(); + assert!(err.to_string().contains("network policy denies"), "{err}"); + } + + #[test] + fn allowlist_resolves_hostnames() { + let al = AllowList::from_hosts(&["localhost"]).unwrap(); + assert!( + al.is_allowed(&"127.0.0.1".parse().unwrap()) + || al.is_allowed(&"::1".parse().unwrap()) + ); + } + + #[test] + fn allowlist_rejects_unresolvable_hostname() { + let result = AllowList::from_hosts(&["this.host.definitely.does.not.exist.example"]); + assert!(result.is_err()); + } + + #[test] + 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); + 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("\"error\""), "net_socket should not exist: {s}"); + } + + #[test] + 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)); + 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}"); + } } diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 8f64c7a..f134a69 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -27,10 +27,11 @@ //! home, //! source: pyhl::InstallSource::Ghcr, //! mounts: &[], +//! network: None, //! force: false, //! })?; //! -//! let mut rt = pyhl::Runtime::new(home, &[Preopen::new("./share", "/host")?])?; +//! let mut rt = pyhl::Runtime::new(home, &[Preopen::new("./share", "/host")?], None)?; //! rt.run_code("print('hello from rust')")?; //! rt.run_code("print('hermetic second call')")?; // fresh __main__ each time //! # Ok(()) @@ -87,6 +88,10 @@ pub struct InstallOptions<'a> { /// during warmup). `Runtime::new` only remaps the host side. pub mounts: &'a [Preopen], + /// Network policy. `None` disables networking; `Some(policy)` + /// enables `net_*` tools with the given restrictions. + pub network: Option<&'a crate::NetworkPolicy>, + /// Overwrite an existing install. pub force: bool, } @@ -183,6 +188,9 @@ pub fn install(opts: &InstallOptions<'_>) -> Result { for p in opts.mounts { builder = builder.preopen(p.clone()); } + if let Some(policy) = opts.network { + builder = builder.network(policy.clone()); + } let mut sbox = builder.build()?; sbox.restore()?; let _: () = sbox.call_named("run", "pass".to_string())?; @@ -233,8 +241,13 @@ impl Runtime { /// Open a runtime against an existing install. Looks for /// `{home}/snapshot.hls` and mmap-loads it. `mounts` specify host /// directories to expose under the guest paths that were baked in - /// at `install` time. - pub fn new(home: &Path, mounts: &[Preopen]) -> Result { + /// at `install` time. `network` enables guest networking with the + /// given policy (`None` = disabled). + pub fn new( + home: &Path, + mounts: &[Preopen], + network: Option<&crate::NetworkPolicy>, + ) -> Result { default_surrogate_count(); let snap = home.join(SNAPSHOT_FILE); if !snap.is_file() { @@ -244,13 +257,13 @@ impl Runtime { ); } let initrd = home.join(INITRD_FILE); - let sandbox = if initrd.is_file() { - Sandbox::from_snapshot_file_with_initrd(&snap, mounts, &initrd)? - } else if mounts.is_empty() { - Sandbox::from_snapshot_file(&snap)? + let initrd_ref = if initrd.is_file() { + Some(initrd.as_path()) } else { - Sandbox::from_snapshot_file_with(&snap, mounts)? + None }; + let sandbox = + Sandbox::from_snapshot_file_configured(&snap, mounts, initrd_ref, network)?; Ok(Self { sandbox, first_run: true, From e032eca34e83b72198854ef6ae2281e6d5ab343f Mon Sep 17 00:00:00 2001 From: danbugs Date: Wed, 13 May 2026 17:04:51 +0000 Subject: [PATCH 10/15] host: add --net and --net-allow CLI flags to hyperlight-unikraft and pyhl Signed-off-by: danbugs --- examples/networking-py/Justfile | 8 +++--- host/src/bin/pyhl.rs | 49 +++++++++++++++++++++++++++++---- host/src/main.rs | 24 +++++++++++++++- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/examples/networking-py/Justfile b/examples/networking-py/Justfile index 10e853f..ee4c364 100644 --- a/examples/networking-py/Justfile +++ b/examples/networking-py/Justfile @@ -16,19 +16,19 @@ image := "networking-py-hyperlight" # Run HTTP GET test (raw sockets) run-get: - hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /http_get.py + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /http_get.py # Run HTTP GET test (urllib — high-level stdlib) run-urllib: - hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /urllib_get.py + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /urllib_get.py # Run HTTPS (TLS) test run-https: - hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /https_test.py + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /https_test.py # Run echo server run-echo: - hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} -- /echo_server.py + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /echo_server.py # Build rootfs via Docker (cross-platform) rootfs: diff --git a/host/src/bin/pyhl.rs b/host/src/bin/pyhl.rs index 8e29b57..c8652e3 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::{Preopen, Sandbox}; +use hyperlight_unikraft::{AllowList, NetworkPolicy, Preopen, Sandbox}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -37,6 +37,16 @@ fn parse_mount(spec: &str) -> Result { Preopen::parse_cli(spec).map_err(|e| anyhow!("invalid --mount {:?}: {}", spec, e)) } +fn build_network_policy(net: bool, net_allow: &[String]) -> Result> { + if !net_allow.is_empty() { + Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts(net_allow)?))) + } else if net { + Ok(Some(NetworkPolicy::AllowAll)) + } else { + Ok(None) + } +} + /// 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 — @@ -145,6 +155,15 @@ struct SetupArgs { /// given to `setup`. #[arg(long = "mount", value_name = "HOST[:GUEST]")] mounts: Vec, + + /// Enable guest networking. + #[arg(long)] + net: bool, + + /// Restrict guest networking to the listed hosts/IPs. + /// Implies --net. Repeatable. + #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + net_allow: Vec, } #[derive(Args)] @@ -172,6 +191,15 @@ struct RunArgs { #[arg(long = "mount", value_name = "HOST[:GUEST]")] mounts: Vec, + /// Enable guest networking. + #[arg(long)] + net: bool, + + /// Restrict guest networking to the listed hosts/IPs. + /// Implies --net. Repeatable. + #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + net_allow: 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")] @@ -329,6 +357,7 @@ fn cmd_setup(args: SetupArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; + let network = build_network_policy(args.net, &args.net_allow)?; eprintln!("pyhl: warming up Python and persisting snapshot…"); let t_warm = Instant::now(); @@ -339,6 +368,9 @@ fn cmd_setup(args: SetupArgs) -> Result<()> { for p in &setup_preopens { builder = builder.preopen(p.clone()); } + if let Some(ref policy) = network { + builder = builder.network(policy.clone()); + } let mut sbox = builder.build()?; sbox.restore()?; let _: () = sbox.call_named("run", "pass".to_string())?; @@ -463,17 +495,22 @@ fn cmd_run(args: RunArgs) -> Result<()> { .iter() .map(|m| parse_mount(m)) .collect::>()?; + let network = build_network_policy(args.net, &args.net_allow)?; let initrd = home.join(INITRD_FILE); let t_load = Instant::now(); - let mut sandbox = if initrd.is_file() { - Sandbox::from_snapshot_file_with_initrd(&snapshot, &run_preopens, &initrd)? - } else if run_preopens.is_empty() { - Sandbox::from_snapshot_file(&snapshot)? + let initrd_ref = if initrd.is_file() { + Some(initrd.as_path()) } else { - Sandbox::from_snapshot_file_with(&snapshot, &run_preopens)? + None }; + let mut sandbox = Sandbox::from_snapshot_file_configured( + &snapshot, + &run_preopens, + initrd_ref, + network.as_ref(), + )?; if args.verbose { eprintln!( "[pyhl] load_snapshot={:.1}ms", diff --git a/host/src/main.rs b/host/src/main.rs index dd12eb9..5b481d4 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -8,7 +8,7 @@ use anyhow::Result; use clap::Parser; -use hyperlight_unikraft::{parse_memory, Preopen, Sandbox}; +use hyperlight_unikraft::{parse_memory, AllowList, NetworkPolicy, Preopen, Sandbox}; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -57,6 +57,17 @@ struct Args { #[arg(long, value_name = "HOST[:GUEST]")] mount: Vec, + /// Enable guest networking. Without this flag, the guest has no + /// network access. + #[arg(long)] + net: bool, + + /// Restrict guest networking to the listed hosts/IPs. + /// Implies --net. Hostnames are resolved at sandbox creation time. + /// Repeatable: `--net-allow api.github.com --net-allow 10.0.0.1`. + #[arg(long = "net-allow", value_name = "HOST_OR_IP")] + net_allow: 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")] @@ -152,6 +163,14 @@ fn main() -> Result<()> { None => args.app_args.clone(), }; + let network = if !args.net_allow.is_empty() { + Some(NetworkPolicy::AllowList(AllowList::from_hosts(&args.net_allow)?)) + } else if args.net { + Some(NetworkPolicy::AllowAll) + } else { + None + }; + let mut builder = Sandbox::builder(&args.kernel) .args(app_args) .heap_size(heap_size) @@ -162,6 +181,9 @@ fn main() -> Result<()> { for p in preopens { builder = builder.preopen(p); } + if let Some(policy) = network { + builder = builder.network(policy); + } if args.enable_tools { builder = builder.tool("echo", Ok); } From 4ee2a180e21b67686cf7149ecf3efa0005c491f8 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 05:54:49 +0000 Subject: [PATCH 11/15] python-agent-driver: enable networking in rootfs and kernel Add hostsock networking config to kraft.yaml (pointing to danbugs/unikraft networking branch) and bundle DNS resolv.conf, nsswitch.conf, hosts, and CA certificates into the CPIO rootfs so the guest can resolve hostnames and make TLS connections. Signed-off-by: danbugs --- examples/python-agent-driver/Dockerfile | 15 ++++++++++++++- examples/python-agent-driver/kraft.yaml | 13 +++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/examples/python-agent-driver/Dockerfile b/examples/python-agent-driver/Dockerfile index 36d8659..3744d6f 100644 --- a/examples/python-agent-driver/Dockerfile +++ b/examples/python-agent-driver/Dockerfile @@ -44,6 +44,19 @@ COPY --from=driver-build /src/hl_pydriver /bin/hl_pydriver # Stage 4: pack CPIO. FROM alpine:3.20 AS cpio -RUN apk add --no-cache cpio findutils +RUN apk add --no-cache cpio findutils ca-certificates COPY --from=rootfs / /rootfs/ + +# DNS configuration for glibc's getaddrinfo +RUN mkdir -p /rootfs/etc && \ + echo "nameserver 8.8.8.8" > /rootfs/etc/resolv.conf && \ + echo "nameserver 8.8.4.4" >> /rootfs/etc/resolv.conf && \ + echo "hosts: files dns" > /rootfs/etc/nsswitch.conf && \ + echo "127.0.0.1 localhost" > /rootfs/etc/hosts + +# CA certificates for TLS +RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/usr/lib/ssl && \ + cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ && \ + ln -sf /etc/ssl/certs/ca-certificates.crt /rootfs/usr/lib/ssl/cert.pem + RUN cd /rootfs && find . | cpio -o -H newc > /output.cpio 2>/dev/null diff --git a/examples/python-agent-driver/kraft.yaml b/examples/python-agent-driver/kraft.yaml index 280d421..114c5e9 100644 --- a/examples/python-agent-driver/kraft.yaml +++ b/examples/python-agent-driver/kraft.yaml @@ -2,8 +2,8 @@ specification: '0.6' name: python-agent-driver-hyperlight unikraft: - source: https://github.com/unikraft/unikraft.git - version: plat-hyperlight + source: https://github.com/danbugs/unikraft.git + version: networking kconfig: CONFIG_PLAT_HYPERLIGHT: 'y' CONFIG_PAGING: 'n' @@ -34,6 +34,15 @@ unikraft: CONFIG_LIBHOSTFS: 'y' CONFIG_LIBHOSTFS_AUTOMOUNT: 'y' + # Networking: host-proxied sockets + CONFIG_LIBPOSIX_SOCKET: 'y' + CONFIG_LIBHOSTSOCK: 'y' + + # FD infrastructure (required by posix-socket) + CONFIG_LIBPOSIX_FDIO: 'y' + CONFIG_LIBPOSIX_FDTAB: 'y' + CONFIG_LIBUKFILE: 'y' + # Target our driver binary, not /usr/local/bin/python3. CONFIG_APPELFLOADER: 'y' CONFIG_APPELFLOADER_VFSEXEC: 'y' From 8692e016041a1a47fe14f4b39e41a1f57aab0fc6 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 05:57:06 +0000 Subject: [PATCH 12/15] chore: cargo fmt Signed-off-by: danbugs --- host/src/bin/pyhl.rs | 4 +++- host/src/lib.rs | 26 ++++++++++++-------------- host/src/main.rs | 4 +++- host/src/pyhl.rs | 3 +-- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/host/src/bin/pyhl.rs b/host/src/bin/pyhl.rs index c8652e3..9c83571 100644 --- a/host/src/bin/pyhl.rs +++ b/host/src/bin/pyhl.rs @@ -39,7 +39,9 @@ fn parse_mount(spec: &str) -> Result { fn build_network_policy(net: bool, net_allow: &[String]) -> Result> { if !net_allow.is_empty() { - Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts(net_allow)?))) + Ok(Some(NetworkPolicy::AllowList(AllowList::from_hosts( + net_allow, + )?))) } else if net { Ok(Some(NetworkPolicy::AllowAll)) } else { diff --git a/host/src/lib.rs b/host/src/lib.rs index 9d321ba..54c7366 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -244,10 +244,7 @@ impl NetworkPolicy { if addr.port() == 53 || al.is_allowed(&addr.ip()) { Ok(()) } else { - Err(anyhow!( - "network policy denies connection to {}", - addr - )) + Err(anyhow!("network policy denies connection to {}", addr)) } } } @@ -1923,12 +1920,7 @@ impl Sandbox { initrd: Option<&Path>, network: Option<&NetworkPolicy>, ) -> 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) } fn from_snapshot_file_full>( @@ -1992,8 +1984,15 @@ pub fn run_vm_with_tools( config: VmConfig, tools: ToolRegistry, ) -> Result<()> { - let _ = - Sandbox::evolve_inline(kernel_path, initrd, app_args, config, Some(tools), &[], None)?; + let _ = Sandbox::evolve_inline( + kernel_path, + initrd, + app_args, + config, + Some(tools), + &[], + None, + )?; Ok(()) } @@ -2459,8 +2458,7 @@ mod tests { fn allowlist_resolves_hostnames() { let al = AllowList::from_hosts(&["localhost"]).unwrap(); assert!( - al.is_allowed(&"127.0.0.1".parse().unwrap()) - || al.is_allowed(&"::1".parse().unwrap()) + al.is_allowed(&"127.0.0.1".parse().unwrap()) || al.is_allowed(&"::1".parse().unwrap()) ); } diff --git a/host/src/main.rs b/host/src/main.rs index 5b481d4..869061e 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -164,7 +164,9 @@ fn main() -> Result<()> { }; let network = if !args.net_allow.is_empty() { - Some(NetworkPolicy::AllowList(AllowList::from_hosts(&args.net_allow)?)) + Some(NetworkPolicy::AllowList(AllowList::from_hosts( + &args.net_allow, + )?)) } else if args.net { Some(NetworkPolicy::AllowAll) } else { diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index f134a69..7b8a077 100644 --- a/host/src/pyhl.rs +++ b/host/src/pyhl.rs @@ -262,8 +262,7 @@ 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)?; Ok(Self { sandbox, first_run: true, From 1729e9747d2507fbb26540e0bb3775bb5d02db7f Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 06:13:23 +0000 Subject: [PATCH 13/15] ci: add networking-py to test-examples workflow Signed-off-by: danbugs --- .github/workflows/test-examples.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 26c9485..651a440 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -65,6 +65,7 @@ jobs: - python-agent - python-agent-driver - powershell + - networking-py steps: - uses: actions/checkout@v4 @@ -232,6 +233,10 @@ jobs: memory: "1Gi" args: "-- -NoProfile -File /scripts/hello.ps1" expect: "Hello, World! From PowerShell on Hyperlight" + - example: networking-py + memory: "512Mi" + args: "--net -- /urllib_get.py" + expect: "SUCCESS: urllib GET worked!" steps: - uses: actions/checkout@v4 @@ -402,6 +407,7 @@ jobs: - python-agent - python-agent-driver - powershell + - networking-py steps: - uses: actions/checkout@v4 @@ -559,6 +565,10 @@ jobs: memory: "1Gi" args: "-- -NoProfile -File /scripts/hello.ps1" expect: "Hello, World! From PowerShell on Hyperlight" + - example: networking-py + memory: "512Mi" + args: "--net -- /urllib_get.py" + expect: "SUCCESS: urllib GET worked!" steps: - uses: actions/checkout@v4 From 2f75cc198c72ed603dd9987e9ad86af3a55969e9 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 14:51:12 +0000 Subject: [PATCH 14/15] kraft.yaml: switch to unikraft/unikraft plat-hyperlight (hostsock merged upstream) Signed-off-by: danbugs --- examples/networking-py/kraft.yaml | 4 ++-- examples/python-agent-driver/kraft.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/networking-py/kraft.yaml b/examples/networking-py/kraft.yaml index cee28e6..67531a4 100644 --- a/examples/networking-py/kraft.yaml +++ b/examples/networking-py/kraft.yaml @@ -2,8 +2,8 @@ specification: '0.6' name: networking-py-hyperlight unikraft: - source: https://github.com/danbugs/unikraft.git - version: networking + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight kconfig: # Platform CONFIG_PLAT_HYPERLIGHT: 'y' diff --git a/examples/python-agent-driver/kraft.yaml b/examples/python-agent-driver/kraft.yaml index 114c5e9..425b327 100644 --- a/examples/python-agent-driver/kraft.yaml +++ b/examples/python-agent-driver/kraft.yaml @@ -2,8 +2,8 @@ specification: '0.6' name: python-agent-driver-hyperlight unikraft: - source: https://github.com/danbugs/unikraft.git - version: networking + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight kconfig: CONFIG_PLAT_HYPERLIGHT: 'y' CONFIG_PAGING: 'n' From fceb11f1ed5db49b86dacb3a4f996f2e01df32f0 Mon Sep 17 00:00:00 2001 From: danbugs Date: Thu, 14 May 2026 15:07:32 +0000 Subject: [PATCH 15/15] ci: reject skipped jobs in umbrella gates Signed-off-by: danbugs --- .github/workflows/benchmarks.yml | 19 ++++++++++++++----- .github/workflows/host-checks.yml | 12 +++++------- .github/workflows/test-examples.yml | 24 +++++++++++++++++++----- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 982c515..4630310 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -536,11 +536,20 @@ jobs: permissions: {} steps: - run: | - results=("${{ needs.build-image.result }}" "${{ needs.bench-linux.result }}" "${{ needs.bench-windows.result }}") - for r in "${results[@]}"; do - if [[ "$r" != "success" && "$r" != "skipped" ]]; then - echo "Job failed with result: $r" - exit 1 + declare -A results=( + [build-image]="${{ needs.build-image.result }}" + [bench-linux]="${{ needs.bench-linux.result }}" + [bench-windows]="${{ needs.bench-windows.result }}" + ) + failed=0 + for job in "${!results[@]}"; do + r="${results[$job]}" + if [[ "$r" != "success" ]]; then + echo "FAIL: $job = $r" + failed=1 fi done + if [[ "$failed" -eq 1 ]]; then + exit 1 + fi echo "All checks passed" diff --git a/.github/workflows/host-checks.yml b/.github/workflows/host-checks.yml index 38c17e9..84a0b78 100644 --- a/.github/workflows/host-checks.yml +++ b/.github/workflows/host-checks.yml @@ -85,11 +85,9 @@ jobs: permissions: {} steps: - run: | - results=("${{ needs.checks.result }}") - for r in "${results[@]}"; do - if [[ "$r" != "success" && "$r" != "skipped" ]]; then - echo "Job failed with result: $r" - exit 1 - fi - done + r="${{ needs.checks.result }}" + if [[ "$r" != "success" ]]; then + echo "FAIL: checks = $r" + exit 1 + fi echo "All checks passed" diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml index 651a440..eb3da35 100644 --- a/.github/workflows/test-examples.yml +++ b/.github/workflows/test-examples.yml @@ -824,11 +824,25 @@ jobs: permissions: {} steps: - run: | - results=("${{ needs.build-example.result }}" "${{ needs.runtime-test.result }}" "${{ needs.package-images-for-windows.result }}" "${{ needs.runtime-test-windows.result }}" "${{ needs.pyhl-snapshot-test.result }}") - for r in "${results[@]}"; do - if [[ "$r" != "success" && "$r" != "skipped" ]]; then - echo "Job failed with result: $r" - exit 1 + # Every job must succeed. "skipped" means a dependency failed + # and the job never ran — that must fail the gate, not sneak + # through as green. + declare -A results=( + [build-example]="${{ needs.build-example.result }}" + [runtime-test]="${{ needs.runtime-test.result }}" + [package-images-for-windows]="${{ needs.package-images-for-windows.result }}" + [runtime-test-windows]="${{ needs.runtime-test-windows.result }}" + [pyhl-snapshot-test]="${{ needs.pyhl-snapshot-test.result }}" + ) + failed=0 + for job in "${!results[@]}"; do + r="${results[$job]}" + if [[ "$r" != "success" ]]; then + echo "FAIL: $job = $r" + failed=1 fi done + if [[ "$failed" -eq 1 ]]; then + exit 1 + fi echo "All checks passed"