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 26c9485..eb3da35 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 @@ -814,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" diff --git a/examples/networking-py/Dockerfile b/examples/networking-py/Dockerfile new file mode 100644 index 0000000..fd24da1 --- /dev/null +++ b/examples/networking-py/Dockerfile @@ -0,0 +1,31 @@ +# 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 +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 ca-certificates +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 + +# 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 new file mode 100644 index 0000000..ee4c364 --- /dev/null +++ b/examples/networking-py/Justfile @@ -0,0 +1,59 @@ +# 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 (raw sockets) +run-get: + 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}} --net -- /urllib_get.py + +# Run HTTPS (TLS) test +run-https: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /https_test.py + +# Run echo server +run-echo: + hyperlight-unikraft {{kernel}} --initrd {{initrd}} --memory {{memory}} --net -- /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..c023455 --- /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 example.com and issues a GET /. +""" +import socket +import sys + +HOST = "172.66.147.243" +PORT = 80 +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: example.com\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/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) diff --git a/examples/networking-py/kraft.yaml b/examples/networking-py/kraft.yaml new file mode 100644 index 0000000..67531a4 --- /dev/null +++ b/examples/networking-py/kraft.yaml @@ -0,0 +1,83 @@ +specification: '0.6' +name: networking-py-hyperlight + +unikraft: + source: https://github.com/unikraft/unikraft.git + version: plat-hyperlight + 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' + + # 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' + + # 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 diff --git a/examples/networking-py/urllib_get.py b/examples/networking-py/urllib_get.py new file mode 100644 index 0000000..167fb82 --- /dev/null +++ b/examples/networking-py/urllib_get.py @@ -0,0 +1,23 @@ +"""High-level HTTP GET using urllib (Python stdlib). + +Demonstrates that Python's standard urllib.request module works +over the host-proxied socket layer with full DNS resolution. +""" +import urllib.request +import sys + +URL = "http://example.com/" + +print(f"Fetching {URL} ...") +try: + 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") + 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) 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..425b327 100644 --- a/examples/python-agent-driver/kraft.yaml +++ b/examples/python-agent-driver/kraft.yaml @@ -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' 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" 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/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/bin/pyhl.rs b/host/src/bin/pyhl.rs index 8e29b57..9c83571 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,18 @@ 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 +157,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 +193,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 +359,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 +370,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 +497,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/lib.rs b/host/src/lib.rs index b3bdf1b..54c7366 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,102 @@ 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 +888,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,6 +908,348 @@ fn register_internal_tools(tools: &mut ToolRegistry, exit_code: &Arc) } Ok(serde_json::json!({})) }); + if let Some(policy) = network { + register_net_tools(tools, policy); + } +} + +// --------------------------------------------------------------------------- +// 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 { + let family: i32 = match addr { + SocketAddr::V4(_) => 2, + SocketAddr::V6(_) => 10, + }; + serde_json::json!({ + "family": family, + "addr": addr.ip().to_string(), + "port": addr.port(), + }) +} + +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(); + 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(); + 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)?; + 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(); + 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"] + .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)?; + pol.check(&addr)?; + 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 == 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 + } 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 @@ -1092,6 +1533,7 @@ pub struct SandboxBuilder { heap_size: Option, stack_size: Option, preopens: Vec, + network: Option, tools: ToolRegistry, has_tools: bool, } @@ -1147,6 +1589,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 @@ -1168,6 +1619,7 @@ impl SandboxBuilder { } else { None }; + let net = self.network.as_ref(); match self.initrd { Some(InitrdSource::File(path)) => Sandbox::evolve_mapped( &self.kernel, @@ -1176,6 +1628,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), Some(InitrdSource::Bytes(bytes)) => Sandbox::evolve_inline( &self.kernel, @@ -1184,6 +1637,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), None => Sandbox::evolve_mapped( &self.kernel, @@ -1192,6 +1646,7 @@ impl SandboxBuilder { config, tools, &self.preopens, + net, ), } } @@ -1208,6 +1663,7 @@ impl Sandbox { heap_size: None, stack_size: None, preopens: Vec::new(), + network: None, tools: ToolRegistry::new(), has_tools: false, } @@ -1221,6 +1677,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)); @@ -1236,7 +1693,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 { @@ -1254,6 +1711,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)); @@ -1285,7 +1743,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 { @@ -1424,7 +1882,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 @@ -1439,7 +1897,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 @@ -1451,20 +1909,32 @@ 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(); @@ -1502,7 +1972,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(()) } @@ -1514,7 +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), &[])?; + let _ = Sandbox::evolve_inline( + kernel_path, + initrd, + app_args, + config, + Some(tools), + &[], + None, + )?; Ok(()) } @@ -1527,7 +2005,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(()) } @@ -1554,7 +2032,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 @@ -1948,4 +2427,66 @@ 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/main.rs b/host/src/main.rs index dd12eb9..869061e 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,16 @@ 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 +183,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); } diff --git a/host/src/pyhl.rs b/host/src/pyhl.rs index 8f64c7a..7b8a077 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,12 @@ 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,