* feat(podman): add Podman compute driver for rootless sandbox management Adds openshell-driver-podman, a new compute driver that manages OpenShell sandboxes as rootless Podman containers via the Podman REST API over a Unix socket. Enables local workstation sandboxes without Kubernetes. Driver features: - Bridge networking with ephemeral host-port mapping for rootless SSH reachability - Named volumes for workspace storage, Podman native health checks, GPU via CDI - Supervisor binary sideloaded via image volume mount (BYOC-compatible) - SSH handshake secret injected via Podman secrets API (not plaintext env) - Typed ContainerSpec structs, input validation, and path-traversal guards - Cgroups v2 required; fails fast on v1 hosts - Bounded event stream buffer; watch stream reconnection handled by server watch_loop - Graceful shutdown and standalone driver binary with gRPC bridge Rootless-specific fixes: - Skip drop_privileges when user namespace lacks SETUID/SETGID/DAC_READ_SEARCH caps - Add /run/netns tmpfs mount for ip netns in rootless containers - Use secret_env map (not secrets array) for env-var injection in libpod API - Resolve SSH endpoint to 127.0.0.1:<host_port> instead of unreachable bridge IP Server/sandbox hardening: - Split loopback and link-local SSRF gates; Podman/VM drivers allow loopback - Close SSRF bypass in SSH tunnel Host path by resolving DNS before connecting - Prevent OPENSHELL_* env var override by user-supplied spec environment maps - Disable SQLite pool idle_timeout/max_lifetime for in-memory databases - Emit deleted_event on 404-during-inspect instead of regressing sandbox phase - Key delete cleanup by stable sandbox_id to survive container label drift CLI fixes: - Restore --name as a named flag on sandbox create (not positional) - Fix exec command arg parsing to not consume sandboxed-command flags - Propagate SSH verbosity via OPENSHELL_SSH_LOG_LEVEL Build tooling: - Add tasks/scripts/container-engine.sh: auto-detects Podman or Docker, exposes unified ce_* helpers; all build/cluster/VM scripts updated to use it - Add docker:build:supervisor mise task for standalone supervisor image - Add openshell-driver-podman to Dockerfile.images pre-fetch/build stages - Add e2e/rust/e2e-podman.sh and e2e:podman mise task for full lifecycle testing Signed-off-by: Adam Miller <admiller@redhat.com> * fix(driver-podman): derive grpc endpoint from server bind port When a user starts the gateway on a non-default port (e.g. --port 8081), sandbox containers were receiving OPENSHELL_ENDPOINT pointing at the default port 8080. The driver's auto-detection fallback read OPENSHELL_BIND_ADDRESS from the environment, which was stale or unset, and fell back to DEFAULT_SERVER_PORT. Add gateway_port to PodmanComputeConfig and thread config.bind_address.port() from the server into the driver so the fallback uses the actual listening port. Remove the OPENSHELL_BIND_ADDRESS env var read and the extract_port_from_bind_address helper which are no longer needed. Add --gateway-port / OPENSHELL_GATEWAY_PORT to the standalone driver binary for parity when the driver is run outside the embedded server path. Signed-off-by: Adam Miller <admiller@redhat.com> * fix(driver-podman): address PR feedback on env test safety and cluster DNS docs Replace hand-rolled unsafe TempEnvVar RAII guard with temp_env::with_vars and a static ENV_LOCK mutex, fixing a data race in parallel test execution. The prior safety comment incorrectly claimed Cargo runs tests single-threaded. Update debug-openshell-cluster skill to accurately document the DNS proxy strategy (setup_dns_proxy + public DNS fallback) and clarify the separation between cluster DNS and sandbox agent DNS enforcement. Signed-off-by: Adam Miller <admiller@redhat.com> * fix(e2e): resolve CI failures in auth timeout, test harness, and formatting - Short-circuit browser_auth_flow when OPENSHELL_NO_BROWSER=1 instead of waiting the full 120s AUTH_TIMEOUT for a callback that never arrives - Add timeout to SandboxGuard::create() and create_with_upload() to prevent indefinite hangs (matches create_keep() which already had one) - Add missing '--' separator in no_proxy test before command args - Add #![cfg(feature = "e2e")] gate to sandbox_lifecycle.rs - Run cargo fmt on openshell-driver-podman - Refine cluster DNS docs for Podman in debug-openshell-cluster skill Signed-off-by: Adam Miller <admiller@redhat.com> * refactor(server): remove allows_loopback_endpoints from ComputeRuntime SSRF protection is now handled at the network and proxy layers (openshell-core net.rs, openshell-sandbox proxy.rs) rather than requiring per-driver flags on ComputeRuntime. Update architecture docs to reflect supervisor relay SSH transport and add rootless networking deep-dive. Signed-off-by: Adam Miller <admiller@redhat.com> --------- Signed-off-by: Adam Miller <admiller@redhat.com>
17 KiB
Rootless Podman Networking
Deep-dive into how networking works in the Podman compute driver when running rootless with pasta as the network backend. Covers the external tooling (Podman, Netavark, pasta, aardvark-dns), the three nested namespace layers, and the complete data paths for SSH, outbound traffic, and supervisor-to-gateway communication.
For the general Podman driver architecture (lifecycle, API surface, driver comparison), see podman-driver.md.
Component Stack
Podman's networking is composed of four independent projects:
| Component | Language | Role |
|---|---|---|
| Podman | Go | Container runtime; orchestrates network lifecycle |
| Netavark | Rust | Network backend; creates interfaces, bridges, firewall rules |
| aardvark-dns | Rust | Authoritative DNS server for container name resolution (A/AAAA records) |
| pasta (part of passt) | C | User-mode networking; L2-to-L4 socket translation for rootless containers |
The key split: rootful containers default to Netavark (bridge networking with real kernel interfaces), while rootless containers default to pasta (user-mode networking, no privileges needed).
How Netavark Works (Rootful)
Netavark is invoked by Podman as an external binary. It reads a JSON network configuration from STDIN and executes one of three commands:
netavark setup <netns-path>-- creates interfaces, assigns IPs, sets up firewall rules for NAT/port-forwardingnetavark teardown <netns-path>-- reverses setup; removes interfaces and firewall rulesnetavark create-- takes a partial network config and completes it (assigns subnets, gateways)
For rootful bridge networking:
- Podman creates a network namespace for the container
- Podman invokes
netavark setuppassing the network config JSON - Netavark creates a bridge (e.g.,
podman0) if it doesn't exist -- default subnet is10.88.0.0/16 - Netavark creates a veth pair -- one end goes into the container's netns, the other attaches to the bridge
- Netavark assigns an IP from the subnet to the container's veth interface (host-local IPAM)
- Netavark configures iptables/nftables rules -- masquerade for outbound, DNAT for port mappings
- Netavark starts aardvark-dns if DNS is enabled, listening on the bridge gateway address
Host Kernel
|
+-- Bridge interface (e.g., "podman0") <-- created by Netavark
| |
| +-- veth pair endpoint (host side, container 1)
| +-- veth pair endpoint (host side, container 2)
|
+-- Host physical interface (e.g., eth0)
|
+-- NAT (iptables/nftables rules managed by Netavark)
Netavark also supports macvlan networks (container gets a sub-interface of a physical host NIC with its own MAC, appearing directly on the physical network) and external plugins via a documented JSON API.
How Pasta Works (Rootless)
The Problem
Unprivileged users cannot create network interfaces on the host. They cannot create veth pairs, bridges, or configure iptables rules. Netavark's bridge approach cannot work directly for rootless containers.
The Solution
Pasta (part of the passt project -- same binary, different command name) operates entirely in userspace, translating between the container's L2 TAP interface and the host's L4 sockets. It requires no capabilities or privileges.
Container Network Namespace
|
+-- TAP device (e.g., "eth0")
| ^
| | L2 frames (Ethernet)
| v
+-- pasta process (userspace)
|
| Translation: L2 frames <-> L4 sockets
|
v
Host Network Stack (native TCP/UDP/ICMP sockets)
Detailed Data Path
For an outbound TCP connection from a container:
- The application calls
connect()to an external address - The kernel routes the packet through the default gateway to the TAP device
- Pasta reads the raw Ethernet frame from the TAP file descriptor
- Pasta parses L2/L3/L4 headers and identifies the TCP SYN
- Pasta opens a native TCP socket on the host and calls
connect()to the same destination - When the host socket connects, pasta reflects the SYN-ACK back through the TAP as an L2 frame
- For ongoing data transfer, pasta translates between TAP frames and the host socket, coordinating TCP windows and acknowledgments between the two sides
Pasta does NOT maintain per-connection packet buffers -- it reflects observed sending windows and ACKs directly between peers. This is a thinner translation layer than a full TCP/IP stack (like slirp4netns used).
Built-in Services
Pasta includes minimalistic network services so the container's stack can auto-configure:
| Service | Purpose |
|---|---|
| ARP proxy | Resolves the gateway address to the host's MAC address |
| DHCP server | Hands out a single IPv4 address (same as host's upstream interface) |
| NDP proxy | Handles IPv6 neighbor discovery, SLAAC prefix advertisement |
| DHCPv6 server | Hands out a single IPv6 address (same as host's upstream interface) |
By default there is no NAT -- pasta copies the host's IP addresses into the container namespace.
Local Connection Bypass (Splice Path)
For connections between the container and the host, pasta implements a zero-copy bypass:
- Packets with a local destination skip L2 translation entirely
splice(2)for TCP (zero-copy),recvmmsg(2)/sendmmsg(2)for UDP (batched)- Achieves ~38 Gbps TCP throughput for local connections
Port Forwarding
By default, pasta uses auto-detection: it scans /proc/net/tcp and /proc/net/tcp6 periodically and automatically forwards any ports that are bound/listening. Port forwarding is fully configurable via pasta options.
Security Properties
- No dynamic memory allocation (
sbrk,brk,mmapblocked via seccomp) - All capabilities dropped (except
CAP_NET_BIND_SERVICEif granted) - Restrictive seccomp profiles (43 syscalls allowed on x86_64)
- Detaches into its own user, mount, IPC, UTS, PID namespaces
- No external dependencies beyond libc
- ~5,000 lines of code target
Inter-Container Limitation
Unlike bridge networking, pasta containers are isolated from each other by default. No virtual bridge connects them. Communication requires port mappings through the host, pods (shared network namespace), or opting into rootless Netavark bridge networking via podman network create.
Three Nested Namespaces in the Podman Driver
The Podman compute driver creates three layers of network isolation:
Namespace 1: Host
|
pasta manages port forwarding (127.0.0.1:<ephemeral>)
gateway listens on 0.0.0.0:8080
|
Namespace 2: Rootless Podman network namespace (managed by pasta)
|
Bridge "openshell" (10.89.x.0/24)
aardvark-dns for container name resolution
|
Container netns (10.89.x.2)
supervisor, proxy, SSH daemon all run here
|
Namespace 3: Inner sandbox netns (created by supervisor)
|
veth pair (10.200.0.1 <-> 10.200.0.2)
iptables forces all traffic through proxy
user workload runs here
Pasta bridges namespace 1 and 2, the veth pair bridges namespace 2 and 3, and the proxy at the boundary of 2/3 enforces network policy.
Layer 1: Pasta (Rootless Podman Bridge)
At driver startup (driver.rs:104-114), the driver ensures a Podman bridge network exists:
client.ensure_network(&config.network_name).await?;
This creates a bridge network named "openshell" (default from DEFAULT_NETWORK_NAME in openshell-core/src/config.rs) with dns_enabled: true. In rootless mode, this bridge exists inside a user namespace managed by pasta. The bridge IP range (e.g., 10.89.x.x) is not routable from the host.
Host (your machine)
|
127.0.0.1:<ephemeral> <--- pasta binds this on the host
|
[pasta process] <--- translates L4 sockets <-> L2 TAP frames
|
[rootless network namespace]
|
Bridge "openshell" (10.89.1.0/24)
|
+-- 10.89.1.1 (bridge gateway, aardvark-dns listens here)
|
+-- veth --> Container netns
|
10.89.1.2 (container IP)
Layer 2: Container Networking (Pasta Port Forwarding)
The container spec (container.rs:447-471) configures:
nsmode: "bridge"-- uses the Podman bridge networknetworks: {"openshell"}-- attaches to the named bridgeportmappings: [{host_port: 0, container_port: 2222, protocol: "tcp"}]-- publishes SSH on an ephemeral host porthostadd: ["host.containers.internal:host-gateway"]-- resolves to the host IP (pasta uses169.254.1.2in rootless mode)
Pasta is never explicitly configured. The driver sets nsmode: "bridge" and Podman selects pasta automatically as the rootless network backend. The driver logs the detected backend at startup (driver.rs:86):
network_backend = %info.host.network_backend,
The host.containers.internal hostname (the Podman equivalent of Docker's host.docker.internal) is injected into /etc/hosts so the supervisor can reach the gateway on the host. The gRPC callback endpoint is auto-detected at driver.rs:116-130:
if config.grpc_endpoint.is_empty() {
config.grpc_endpoint =
format!("http://host.containers.internal:{}", config.gateway_port);
}
The bridge gateway IP does NOT work for this purpose in rootless mode because it lives inside the user namespace, not on the host.
Layer 3: Inner Sandbox Network Namespace
Inside the container, the supervisor creates another network namespace (netns.rs:53-178, setup at lines 53-63, ip netns add at line 77) for the user workload:
Container (10.89.1.2 on the Podman bridge)
|
[Supervisor process - runs in container's default netns]
|
+-- Proxy listener at 10.200.0.1:3128
|
+-- veth pair: veth-h-{short_id} <-> veth-s-{short_id}
|
+-- Inner network namespace "sandbox-{short_id}" (short_id = first 8 chars of UUID)
|
10.200.0.2/24
|
default route -> 10.200.0.1 (supervisor's proxy)
|
[User's code runs here]
|
iptables rules (IPv4; IPv6 installed best-effort):
ACCEPT -> 10.200.0.1:{proxy_port} TCP (proxy)
ACCEPT -> loopback (-o lo)
ACCEPT -> established/related (conntrack)
LOG -> TCP SYN bypass attempts (rate-limited 5/sec)
REJECT -> TCP (icmp-port-unreachable)
LOG -> UDP bypass attempts (rate-limited 5/sec)
REJECT -> UDP (icmp-port-unreachable)
The supervisor uses nsenter --net= rather than ip netns exec to avoid sysfs remount issues that arise under rootless Podman where real CAP_SYS_ADMIN is unavailable (netns.rs:681-716, function body at 691).
A tmpfs is mounted at /run/netns in the container spec (container.rs:458-463) so the supervisor can create named network namespaces. In rootless Podman this directory does not exist on the host, so mkdir would fail with EPERM without a private tmpfs.
Complete Data Paths
SSH Session: Client to Sandbox Shell
Client (CLI on user's machine)
|
1. gRPC: CreateSshSession -> gateway (returns token, connect_path)
2. HTTP CONNECT /connect/ssh to gateway
(headers: x-sandbox-id, x-sandbox-token)
|
Gateway (host, port 8080)
|
3. Looks up SupervisorSession for sandbox_id
4. Sends RelayOpen{channel_id} over ConnectSupervisor bidi stream
|
[gRPC traverses: host -> pasta L4 translation -> container bridge]
|
Supervisor (inside container at 10.89.x.2)
|
5. Receives RelayOpen, opens new RelayStream RPC back to gateway
6. Sends RelayInit{channel_id} on the stream
7. Connects to Unix socket /run/openshell/ssh.sock
8. Bidirectional bridge: RelayStream <-> Unix socket (16 KiB chunks)
|
SSH daemon (inside container, Unix socket only, root-only permissions)
|
9. Authenticates (all auth accepted -- access gated by relay chain)
10. Spawns shell process
11. Shell enters inner netns via setns(fd, CLONE_NEWNET)
|
User's shell (in sandbox netns at 10.200.0.2)
The SSH daemon listens on a Unix socket (not a TCP port) with 0600 permissions. The published port mapping (host_port: 0 -> container_port: 2222) exists in the container spec but is currently inert -- nothing listens on TCP 2222 inside the container. All SSH communication uses the gRPC reverse-connect relay pattern exclusively.
Outbound HTTP Request from Sandbox Process
User's code (inner netns, 10.200.0.2)
|
1. curl https://api.example.com
(HTTP_PROXY=http://10.200.0.1:3128 set via environment)
|
2. TCP connect to 10.200.0.1:3128
(allowed by iptables -- only permitted egress destination)
|
3. HTTP CONNECT api.example.com:443
|
Supervisor proxy (10.200.0.1:3128 in container netns)
|
4. OPA policy evaluation (process identity via /proc/net/tcp -> PID)
5. SSRF check (block internal IPs unless allowed by policy)
6. Optional L7: TLS intercept, HTTP method/path inspection
|
7. If allowed: TCP connect to api.example.com:443
(from container netns, 10.89.x.2)
|
8. Through Podman bridge -> pasta L2-to-L4 -> host -> internet
Supervisor gRPC Callback to Gateway
Supervisor (container netns, 10.89.x.2)
|
1. gRPC connect to http://host.containers.internal:8080
(resolves to 169.254.1.2:8080 via /etc/hosts)
|
2. Routed through container default gateway (bridge)
|
3. Pasta translates: L2 frame -> host L4 socket
(pasta host-gateway mapping: 169.254.1.2 -> 127.0.0.1)
|
4. Host TCP socket connects to 127.0.0.1:8080
|
Gateway (host, port 8080)
|
5. ConnectSupervisor bidirectional stream established
6. Heartbeats every N seconds (gateway sends interval in SessionAccepted, default 15s)
7. Reconnects with exponential backoff (1s initial, 30s max) on failure
8. Same gRPC channel reused for RelayStream calls (no new TLS handshake)
Differences from the Kubernetes Driver
| Aspect | Kubernetes | Podman (rootless pasta) |
|---|---|---|
| Container/Pod IP | Routable cluster-wide | Non-routable (10.89.x.x inside user namespace) |
| Network reachability | Pod IPs reachable from gateway | Bridge not routable from host; requires pasta port forwarding or host.containers.internal |
| Sandbox -> Gateway | Direct TCP to K8s service IP | host.containers.internal (169.254.1.2 via pasta) |
| SSH transport | Reverse gRPC relay (ConnectSupervisor + RelayStream) -- same mechanism as Podman |
Reverse gRPC relay (ConnectSupervisor + RelayStream) |
| Port publishing | Not needed (routable IPs) | Ephemeral host port via pasta port forwarding |
| TLS | mTLS via K8s secrets | Disabled by default (loopback-only, --disable-tls) |
| DNS | Kubernetes CoreDNS | Podman bridge DNS (aardvark-dns, dns_enabled: true) |
| Network policy | K8s NetworkPolicy (ingress restricted to gateway) | iptables inside inner sandbox netns |
| Supervisor delivery | hostPath volume from k3s node | OCI image volume mount (FROM scratch image) |
| Secrets | K8s Secret volume mount (TLS certs); SSH handshake secret via env var | Podman secret_env API (hidden from podman inspect) |
Both drivers use the same reverse gRPC relay (ConnectSupervisor + RelayStream) for SSH transport. The most significant difference is network reachability: in rootless Podman, the bridge network is not routable from the host, so all communication between host and container goes through either pasta port forwarding (portmappings) or the host.containers.internal hostname (resolved to 169.254.1.2 by pasta).
Port Assignments
| Port | Component | Purpose |
|---|---|---|
| 8080 | Gateway | gRPC + HTTP multiplexed (default DEFAULT_SERVER_PORT) |
| 2222 | Sandbox | Port mapping in container spec (default DEFAULT_SSH_PORT); currently inert -- SSH daemon uses Unix socket only |
| 3128 | Sandbox proxy | HTTP CONNECT proxy (inside container, on inner netns host side) |
| 0 (ephemeral) | Host (via pasta) | Published mapping for container SSH port |
Key Source Files
| File | What it controls |
|---|---|
crates/openshell-driver-podman/src/driver.rs |
Bridge network creation, gRPC endpoint auto-detection, rootless checks |
crates/openshell-driver-podman/src/container.rs |
Container spec: network mode, port mappings, hostadd, tmpfs, capabilities |
crates/openshell-driver-podman/src/client.rs |
Podman REST API calls for network ensure/inspect, port discovery |
crates/openshell-driver-podman/src/config.rs |
Network name, socket path, SSH port, gateway port defaults |
crates/openshell-sandbox/src/sandbox/linux/netns.rs |
Inner network namespace: veth pair, IP addressing, iptables rules |
crates/openshell-sandbox/src/proxy.rs |
HTTP CONNECT proxy: OPA policy, SSRF protection, L7 inspection |
crates/openshell-sandbox/src/ssh.rs |
SSH daemon on Unix socket, shell process netns entry via setns() |
crates/openshell-sandbox/src/supervisor_session.rs |
gRPC ConnectSupervisor stream, RelayStream for SSH tunneling |
crates/openshell-sandbox/src/grpc_client.rs |
gRPC channel to gateway (mTLS or plaintext, keep-alive, adaptive windowing) |
crates/openshell-server/src/ssh_tunnel.rs |
Gateway-side SSH tunnel: HTTP CONNECT endpoint, relay bridging |
crates/openshell-server/src/supervisor_session.rs |
SupervisorSessionRegistry, relay claim/open lifecycle |
crates/openshell-server/src/compute/mod.rs |
ComputeRuntime::new_podman() -- Podman compute driver initialization |
crates/openshell-core/src/config.rs |
Default constants: ports, network name |