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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- Every hluk upgrade left the warm snapshots of each guest in the cache for good: a snapshot another hluk build saved, which this one refuses, was pruned only once its rootfs was rebuilt. `hluk run` now removes those of the same rootfs when it saves its own.
- CI's benchmark jobs, x86 and arm64, failed when another job's load on a shared runner put one run over a ceiling in `benchmarks/limits.json`. They now measure such a timing again, and fail only if the second run is over too.
- A guest given a network policy and no listen ports could bind any port: `hluk run --net` with no `--port`, a manifest with `[run.net]` and no `ports`, or `SandboxBuilder::network` without `listen_ports` let the guest serve, where `--port`'s help and [net.md](docs/net.md) promise a guest that cannot. `bind()` is refused with `EACCES` now unless the port is listed, whether the guest means to listen on it or to connect from it (`source_address`); an ephemeral bind, port 0, is always allowed, so the guest can still serve on a port the host picks. A guest that serves on a port of its own needs `--port`, as the documentation said, and an embedder that served under `network` alone adds `listen_ports`; the CPython conformance recipes pass `--port all`, since those tests bind as they please and the test host is the boundary.
- `os.chmod` (and every `chmod`) on a mount failed with `EIO`: the host took the mode as unsigned and the kernel sends it signed, so the host call was refused before it ran. It now reaches the host file, permission bits only: a guest cannot set setuid, setgid or sticky.
- DNS messages are read only as far as a resolver could be asked: a name longer than 253 characters, a label with a dot or a byte that is not printable ASCII, or an extended label type makes the message no well-formed query (refused) or answer (ignored). The label `a.b` read as the two labels `a` and `b`, and a forged answer could spell out a name kilobytes long through a compression loop.
- A `sendto` on a connected TCP socket was judged by the address it named, though the data goes to the socket's peer: a guest connected to a resolver on port 53 could send anything there by naming an address the policy allows, past the check of its DNS questions. A `sendto` on a stream socket is a `send` to its peer now, checked as one.
Expand Down
4 changes: 2 additions & 2 deletions docs/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ let mut guest = SandboxBuilder::from_initrd(rootfs)

Listen ports need no outbound policy: without `.network(...)`, the guest may serve on them and reach nothing, as under an allow list that lists nothing — what a TCP server that only answers requests needs (its replies go over the accepted connections). A UDP service cannot answer so: each reply is a `sendto()` to the client, an outbound destination like any other, so it needs a policy that allows its clients.

Ephemeral binds (port 0 — "assign any port") are always allowed. `ListenPorts::all()` permits every port, and `with_range(8000..=8010)` a range; on the command line these are `--port all` and `--port 8000-8010`, next to `--port 80`. `all` is for an embedder whose own boundary already scopes what the guest exposes, such as a container runtime, where the guest binds inside the container's network namespace the way any container process does.
Without `ListenPorts`, every bind but an ephemeral one is refused with `EACCES`, a bind that only picks a source port for an outbound connection (`source_address`, `Dialer.LocalAddr`) included: a guest given a policy and no ports can serve only on a port the host picks. Ephemeral binds (port 0 — "assign any port") are always allowed. `ListenPorts::all()` permits every port, and `with_range(8000..=8010)` a range; on the command line these are `--port all` and `--port 8000-8010`, next to `--port 80`. `all` is for an embedder whose own boundary already scopes what the guest exposes, such as a container runtime, where the guest binds inside the container's network namespace the way any container process does.

### Resolver configuration

Expand All @@ -183,7 +183,7 @@ hluk run --net-allow example.com --net-allow 10.0.0.5 ...
# BlockList — block these hosts (implies --net):
hluk run --net-block evil.com --net-block 1.2.3.4 ...

# Restrict inbound listen ports:
# Let the guest serve on ports 80 and 443:
hluk run --net --port 80 --port 443 ...

# Serve on a port, reach nothing:
Expand Down
2 changes: 1 addition & 1 deletion integrations/smolagents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ smolagents then passes `executor_kwargs` to `HyperlightExecutor` and installs th
|---|---|---|
| `additional_imports` | none | Packages to install in the guest, with pip for those not in the image (which needs `net_allow` to include the package index) |
| `logger` | an `AgentLogger` at `INFO` | |
| `net_allow` | none | Hosts or IPs the guest may connect to. With none, every connection is refused and the guest cannot listen on a port |
| `net_allow` | none | Hosts or IPs the guest may connect to. With none, every connection is refused. Either way the guest cannot listen on a port of its own; `hluk_args=["--port", ...]` opens one |
| `runtime` | `"agent"` | Published image to run, by name or full reference |
| `initrd` | | Path to a rootfs CPIO of your own to run instead (it must have CPython 3) |
| `scratch_mb` | the image's | Guest memory in MiB |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ class HyperlightExecutor(RemotePythonExecutor):
initrd (`str`, *optional*): Path to a custom rootfs CPIO to run instead of a published runtime.
scratch_mb (`int`, *optional*): Guest memory in MiB. Defaults to the size the runtime image is tested with.
net_allow (`list[str]`, *optional*): Hosts or IPs the guest may connect to. Defaults to none: every connection
is refused, and the guest cannot listen on any port.
is refused. Either way the guest cannot listen on a port of its own; `hluk_args=["--port", ...]` opens one.
hluk_args (`list[str]`, *optional*): Additional arguments to pass to `hluk run`, e.g. `["--mount", "/data:/data:ro"]`.
startup_timeout (`float`, default `300.0`): Seconds to wait for the guest to be ready. The first run pulls the
runtime image and saves its warm snapshot, which can take a while; later runs start in milliseconds.
Expand Down
16 changes: 10 additions & 6 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -1460,10 +1460,12 @@ conformance runtime *modules:
if [ ! -d "$snap_dir" ]; then
echo "==> Saving conformance snapshot..."
mkdir -p "$(dirname "$snap_dir")"
# Every port: CPython's own tests bind source ports and listeners
# as they please, and the test host is the boundary here.
"$hluk" snapshot save \
--initrd "$rootfs" \
--scratch-mb "$scratch" \
--net \
--net --port all \
--output "$snap_dir"
fi

Expand All @@ -1476,7 +1478,7 @@ conformance runtime *modules:
# One module per line to avoid output truncation; read in a loop,
# not by mapfile, which macOS's bash 3.2 lacks.
test_modules=()
while IFS= read -r mod; do test_modules+=("$mod"); done < <("$hluk" snapshot run "$snap_dir" --net --exec \
while IFS= read -r mod; do test_modules+=("$mod"); done < <("$hluk" snapshot run "$snap_dir" --net --port all --exec \
"import os; d='/usr/local/lib/python3.12/test'; [print(f[:-3]) for f in sorted(os.listdir(d)) if f.startswith('test_') and f.endswith('.py')]" \
2>/dev/null | grep "^test_" || true)
fi
Expand Down Expand Up @@ -1526,7 +1528,7 @@ conformance runtime *modules:
inline=$(printf "MODULE='%s'\n%s" "$mod" "$(cat '{{conformance_dir}}/{{runtime}}/run_tests.py')")

output=$(limited "$hluk" snapshot run "$snap_dir" \
--net --exec "$inline" 2>/dev/null || echo "RESULT $mod status=CRASH tests=0 fail=0 error=0 skip=0 time=0")
--net --port all --exec "$inline" 2>/dev/null || echo "RESULT $mod status=CRASH tests=0 fail=0 error=0 skip=0 time=0")

# Extract the RESULT line
result_line=$(echo "$output" | grep "^RESULT " | tail -1)
Expand Down Expand Up @@ -1579,7 +1581,9 @@ conformance runtime *modules:
if (-not (Test-Path $snapDir)) {
Write-Output "==> Saving conformance snapshot..."
New-Item -ItemType Directory -Force (Split-Path $snapDir) | Out-Null
& $hluk snapshot save --initrd $rootfs --scratch-mb $scratch --net --output $snapDir
# Every port: CPython's own tests bind source ports and listeners
# as they please, and the test host is the boundary here.
& $hluk snapshot save --initrd $rootfs --scratch-mb $scratch --net --port all --output $snapDir
if ($LASTEXITCODE -ne 0) { Write-Error "snapshot save failed" }
}

Expand All @@ -1590,7 +1594,7 @@ conformance runtime *modules:
} else {
Write-Output "==> Discovering test modules..."
$discover = "import os; d='/usr/local/lib/python3.12/test'; [print(f[:-3]) for f in sorted(os.listdir(d)) if f.startswith('test_') and f.endswith('.py')]"
$testModules = @(& $hluk snapshot run $snapDir --net --exec $discover 2>$null | ForEach-Object { $_.Trim() } | Where-Object { $_ -like 'test_*' })
$testModules = @(& $hluk snapshot run $snapDir --net --port all --exec $discover 2>$null | ForEach-Object { $_.Trim() } | Where-Object { $_ -like 'test_*' })
if ($LASTEXITCODE -ne 0) { Write-Error "module discovery failed (exit $LASTEXITCODE)" }
}
if ($testModules.Count -eq 0) { Write-Error "no test modules discovered" }
Expand All @@ -1613,7 +1617,7 @@ conformance runtime *modules:
# Each module runs in its own guest, killed after 60 s (some spin the hypervisor).
$line = "RESULT $mod status=CRASH tests=0 fail=0 error=0 skip=0 time=0"
$psi = [System.Diagnostics.ProcessStartInfo]::new($hluk)
foreach ($a in @('snapshot', 'run', $snapDir, '--net', '--exec', "MODULE='$mod'`n$runner")) { $psi.ArgumentList.Add($a) }
foreach ($a in @('snapshot', 'run', $snapDir, '--net', '--port', 'all', '--exec', "MODULE='$mod'`n$runner")) { $psi.ArgumentList.Add($a) }
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
Expand Down
8 changes: 5 additions & 3 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,11 @@ pub struct RunArgs {

/// Ports the guest may bind to for inbound connections: a port, a
/// range LOW-HIGH, or `all` (repeatable); replaces a project's ports.
/// Without this flag or a project's ports, bind() is rejected
/// (outbound-only). Without --net, --net-allow, --net-block or a
/// project's policy, the guest serves on them but may connect nowhere.
/// Without this flag or a project's ports, bind() is rejected but for
/// port 0: the guest may serve only on a port the host picks, and may
/// not choose its source port either. Without --net, --net-allow,
/// --net-block or a project's policy, the guest serves on them but may
/// connect nowhere.
#[arg(long = "port", value_name = "PORT|LOW-HIGH|all")]
ports: Vec<PortSpec>,

Expand Down
31 changes: 27 additions & 4 deletions src/hostnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,18 @@ impl Net {
}
}

/// Inbound policy (bind). Ephemeral binds (port 0) are always allowed.
/// Inbound policy (bind). Ephemeral binds (port 0) are always
/// allowed; any other port must be listed, whether the guest means to
/// listen on it or to connect from it, so a guest given no ports can
/// serve only on a port the host picks, as `--port` and
/// `SandboxBuilder::listen_ports` promise.
fn allow_bind(&self, addr: &SocketAddr) -> Res<()> {
if addr.port() == 0 {
return Ok(());
}
match &self.listen_ports {
Some(lp) if addr.port() != 0 && !lp.allows(addr.port()) => Err(errno::EACCES),
_ => Ok(()),
Some(lp) if lp.allows(addr.port()) => Ok(()),
_ => Err(errno::EACCES),
}
}

Expand Down Expand Up @@ -1647,12 +1654,15 @@ mod tests {
/// disconnect, as on Linux (macOS replaces the host socket for both).
#[test]
fn datagram_rebind_keeps_bound_port() {
let net = open_net();
let port = std::net::UdpSocket::bind(loopback(0))
.unwrap()
.local_addr()
.unwrap()
.port();
let net = Net::new(
NetworkPolicy::AllowAll,
Some(ListenPorts::from_ports([port])),
);
let s = udp(&net);
net.setsockopt(s, SOL_SOCKET, SO_REUSEADDR, 1).unwrap();
net.bind(s, loopback(port)).unwrap();
Expand Down Expand Up @@ -1843,6 +1853,19 @@ mod tests {
net.bind(s, loopback(0)).unwrap();
}

/// Without listen ports every bind but an ephemeral one is refused,
/// under a policy that allows everything: the guest can serve only on
/// a port the host picks.
#[test]
fn no_listen_ports_rejects_every_bind() {
let net = Net::new(NetworkPolicy::AllowAll, None);
let s = tcp(&net);
assert_eq!(net.bind(s, loopback(8080)), Err(errno::EACCES));
assert_eq!(net.bind(s, loopback(19_999)), Err(errno::EACCES));
// Ephemeral binds are always allowed.
net.bind(s, loopback(0)).unwrap();
}

#[test]
fn wire_addr_packing() {
let mut buf = Vec::new();
Expand Down
6 changes: 4 additions & 2 deletions src/net_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,8 +639,10 @@ fn resolves_to_now(names: &[String], ip: &IpAddr) -> Option<bool> {
/// Controls which ports a guest may bind to for inbound connections.
///
/// Orthogonal to [`NetworkPolicy`] (which governs *outbound* destinations).
/// Without a `ListenPorts` allowlist, `net_bind` rejects every call
/// (outbound-only mode). Single ports, ranges and "every port" compose:
/// Without a `ListenPorts` allowlist, `net_bind` rejects every call but
/// an ephemeral one (port 0), a bind that only picks a source port
/// included: the guest can serve only on a port the host picks. Single
/// ports, ranges and "every port" compose:
/// [`ListenPorts::all`] is for an embedder whose own boundary, such as a
/// container's network namespace, already scopes what the guest exposes.
#[derive(Clone, Debug)]
Expand Down
3 changes: 3 additions & 0 deletions tests/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ fn python_tcp_bidir() {
let mut sandbox = SandboxBuilder::from_initrd(rootfs)
.scratch_mb(256)
.network(NetworkPolicy::AllowAll)
.listen_ports(ListenPorts::from_ports([9998]))
.boot()
.unwrap();

Expand All @@ -679,6 +680,7 @@ fn python_http_server_client() {
let mut sandbox = SandboxBuilder::from_initrd(rootfs)
.scratch_mb(256)
.network(NetworkPolicy::AllowAll)
.listen_ports(ListenPorts::from_ports([9997]))
.boot()
.unwrap();

Expand Down Expand Up @@ -712,6 +714,7 @@ fn python_threaded_select() {
let mut sandbox = SandboxBuilder::from_initrd(rootfs)
.scratch_mb(256)
.network(NetworkPolicy::AllowAll)
.listen_ports(ListenPorts::from_ports([9123]))
.boot()
.unwrap();

Expand Down
Loading