* fix(core): trust public root CAs alongside the sandbox mTLS CA The supervisor gRPC client only trusted the CA configured via OPENSHELL_TLS_CA, since tonic ClientTlsConfig starts with an empty root store unless with_native_roots()/with_webpki_roots() is also enabled. Deployments where the gateway server certificate is issued by a public CA (e.g. cert-manager against an ACME issuer) caused every supervisor connection to fail the TLS handshake with "UnknownCA", since the sandbox mTLS CA and the server cert issuer were no longer the same. Enable both native and webpki roots in addition to the configured CA. tonic root store is a union of all configured sources, so this does not weaken verification for existing self-signed deployments. webpki-roots (compiled in) is enabled alongside native-roots since the supervisor binary may run in minimal sandbox images without a populated system CA bundle. Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * feat(helm): support external cert-manager issuers and OpenShift Route passthrough Add certManager.serverIssuerRef/clientIssuerRef so the gateway and mTLS client certificates can be issued by a real Issuer/ClusterIssuer (e.g. ACME) instead of only the chart built-in self-signed CA. Add openshiftRoute template for exposing the gateway via a TLS passthrough Route so the gateway keeps terminating its own TLS/mTLS. The server Certificate excludes internal-only SANs (cluster-local, localhost, loopback) when an external issuer is configured, since ACME issuers reject those per CA/Browser Forum baseline requirements. A template-time fail guard catches the misconfiguration at helm install time rather than asynchronously at cert-manager issuance time. Includes Helm unittest coverage for both issuerRef overrides and Route rendering, plus a CI values overlay for lint coverage. Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * docs: document cert-manager external issuer and OpenShift Route Update managing-certificates.mdx with the serverIssuerRef workflow and install-time validation behavior. Add a production section to the OpenShift guide covering passthrough Route with a real certificate. Regenerate Helm README for new certManager and openshiftRoute values. Sync debug-openshell-cluster skill with new troubleshooting steps for ACME issuance failures and supervisor UnknownCA from mismatched CAs. Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(helm,core): address PR review feedback on cert-manager external issuer Addresses all five blocking review items from #2468: 1. Remove .with_native_roots() from supervisor gRPC client -- the supervisor runs inside the user-selected sandbox image, so the image CA bundle is not operator-controlled. Keep .with_webpki_roots() (compiled-in, not user-controlled) alongside the configured CA. 2. Fail at render time when serverIssuerRef.name is set but clientCaFromServerTlsSecret is still true. Add negative Helm test. 3. Remove clientIssuerRef -- changing only clientIssuerRef breaks both directions because trust bundles are not modeled separately. Change serverIssuerRef.kind default from ClusterIssuer to Issuer. 4. Add server.oidc.issuer and server.oidc.audience to the documented OpenShift production Helm command. Add Access Control prerequisite. 5. Fail at render time when openshiftRoute.enabled and disableTls are both true. Add negative Helm test. Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(drivers): strip GATEWAY_TLS_SERVER_NAME from Docker and Podman env Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * fix(helm,drivers): guard default clientCaSecretName and add env-strip tests Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> * feat(tls): SNI-based dual certificate for internal and external server TLS Split the gateway server certificate into two: an internal cert issued by the chart's own CA (for supervisor connections via cluster-local SANs) and an external cert issued by an operator-configured Issuer such as ACME/Let's Encrypt (for CLI and Route access via public SANs). The gateway uses SNI-based certificate selection: connections whose SNI hostname matches external_server_names receive the external cert; all others (including those with no SNI) receive the internal cert. Security improvement: remove .with_webpki_roots() from the supervisor gRPC client so supervisors trust only the chart CA, closing a MITM vector via publicly-trusted certificates in user-supplied container images. Key changes: - Add DualCertResolver with SNI-based cert selection and full test coverage - Add external_cert_path, external_key_path, external_server_names to TlsConfig - Validate partial external cert config (error on cert-without-key or vice versa) - Validate empty external_server_names when external cert is configured - Split cert-manager templates into internal + external Certificate resources - Add Helm guards for misconfigured external issuer (empty serverDnsNames, internal-only SANs with external issuer, conflicting clientCaFromServerTlsSecret) - Update gateway-config.mdx, managing-certificates.mdx, openshift.mdx docs - Update debug-openshell-cluster skill for dual-cert troubleshooting Signed-off-by: Pi Agent <agent@openshell.local> * fix(drivers): strip GATEWAY_TLS_SERVER_NAME in VM driver and correct comments Add the same GATEWAY_TLS_SERVER_NAME environment stripping to the VM compute driver that Docker, Podman, and Kubernetes drivers already perform. Without this, a sandbox user on the VM driver could override the TLS server name the supervisor verifies. Fix stale comments in Docker and Podman drivers that referenced 'with WebPKI roots trusted' — WebPKI roots are explicitly not trusted after the tls-webpki-roots removal. Use tls-ring instead of bare channel for tonic in openshell-core so the TLS API (ClientTlsConfig, Endpoint::tls_config) is available without pulling in any root certificate store. Signed-off-by: Pi Agent <agent@openshell.local> * fix(tls,helm): wildcard SNI matching and Route host validation Add RFC 6125 single-level wildcard matching to DualCertResolver so external_server_names entries like *.example.com correctly match SNI hostnames like gw.example.com. Previously only exact matches worked, silently falling back to the internal cert for wildcard configurations. Add a Helm fail guard in route.yaml that rejects openshiftRoute.host values not listed in certManager.serverDnsNames when an external issuer is configured — catches cert/route hostname mismatches at install time instead of at TLS connect time. Quote the host field in route.yaml for robustness. Signed-off-by: Pi Agent <agent@openshell.local> * fix(helm): address blocking review items — client-CA guard, wildcard Route, serverIssuerRef gate 1. Remove the obsolete guard rejecting serverIssuerRef + clientCaFromServerTlsSecret=true. The internal server certificate is always signed by the chart CA (the same CA that signs the client cert), so clientCaFromServerTlsSecret=true is correct — its filtered ca.crt is exactly the right trust anchor. The old workaround (mounting openshell-ca-tls directly) unnecessarily exposed the CA private key to the gateway container. Remove the client-CA overrides from docs, CI overlay, and production examples. 2. Route host validation now supports wildcard certificates per RFC 6125: single-level wildcards like *.example.com match gateway.example.com but not deep.sub.example.com. Require an explicit openshiftRoute.host when an external issuer is configured — without one, OpenShift generates a hostname absent from serverDnsNames. 3. Reject serverIssuerRef.name when certManager.enabled is false — the external certificate, its Secret mount, and the gateway TLS config all require cert-manager to be enabled. Validated on ROSA (dev.dyee.p3) with branch-built images: - Fresh install with letsencrypt-prod ClusterIssuer - SNI dual-cert: external hostname served Let's Encrypt cert - Supervisor mTLS via internal cert path: ConnectSupervisor accepted - Client CA volume: filtered ca.crt from internal server secret (no key) - CLI connected via Route + OIDC Helm tests: 81 pass across 7 suites. Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> --------- Signed-off-by: Jesse Jaggars <jjaggars@redhat.com> Signed-off-by: Pi Agent <agent@openshell.local>
openshell-driver-vm
Status: Experimental. The VM compute driver is under active development and the interface still has VM-specific plumbing that will be generalized.
Standalone libkrun-backed ComputeDriver for OpenShell. The gateway spawns this binary as a subprocess, talks to it over a Unix domain socket with the openshell.compute.v1.ComputeDriver gRPC surface, and lets it manage per-sandbox microVMs. The runtime (libkrun + libkrunfw + gvproxy), guest OCI unpacker, and sandbox supervisor are embedded directly in the binary; each sandbox boots from a cached immutable bootstrap ext4 root disk plus a per-sandbox writable overlay disk. When the requested sandbox image differs from the bootstrap image, the driver prepares a read-only image ext4 disk inside a bootstrap VM and mounts that unpacked rootfs as the sandbox lowerdir.
How it fits together
flowchart LR
subgraph host["Host process"]
gateway["openshell-server<br/>(compute::vm::spawn)"]
driver["openshell-driver-vm<br/>├── libkrun (VM)<br/>├── gvproxy (net)<br/>└── openshell-sandbox.zst"]
gateway <-->|"gRPC over UDS<br/>compute-driver.sock"| driver
end
subgraph guest["Per-sandbox microVM"]
init["/srv/openshell-vm-<br/>sandbox-init.sh"]
supervisor["/opt/openshell/bin/<br/>openshell-sandbox<br/>(PID 1)"]
init --> supervisor
end
driver -->|"CreateSandbox<br/>boots via libkrun"| guest
supervisor -.->|"gRPC callback<br/>--grpc-endpoint"| gateway
client["openshell-cli"] -->|"SSH proxy<br/>127.0.0.1:<port>"| supervisor
client -->|"CreateSandbox / Watch"| gateway
Sandbox guests execute /opt/openshell/bin/openshell-sandbox as PID 1 inside the VM. gvproxy exposes a single inbound SSH port (host:<allocated> → guest:2222) and provides virtio-net egress.
Quick start (recommended)
mise run gateway:vm
First run takes a few minutes while mise run vm:setup stages libkrun/libkrunfw/gvproxy/umoci and mise run vm:supervisor builds the bundled guest supervisor. Subsequent runs are cached.
By default mise run gateway:vm:
- Listens on plaintext HTTP at
127.0.0.1:18081. - Registers the CLI gateway
vm-devby writing~/.config/openshell/gateways/vm-dev/metadata.json. It does not modify the workspace.env. - Persists the gateway SQLite DB under
.cache/gateway-vm/gateway.db. - Places the VM driver state (per-sandbox
overlay.ext4, image cache, andrun/compute-driver.sock) under/tmp/openshell-vm-driver-$USER-vm-dev/so the AF_UNIX socket path stays under macOSSUN_LEN. - Writes
.cache/gateway-vm/gateway.tomlwith[openshell.drivers.vm].driver_dir = "$PWD/target/debug"so the freshly builtopenshell-driver-vmis used instead of an older installed copy from~/.local/libexec/openshell,/usr/libexec/openshell, or/usr/local/libexec.
For GPU passthrough (VFIO), pass -- --gpu and run with root privileges:
sudo -E env "PATH=$PATH" mise run gateway:vm -- --gpu
GPU passthrough uses VFIO and requires host support for IOMMU, root privileges
for bind/unbind operations, and a compatible sandbox image. The public GPU
overview lives in the repository README.md.
Point the CLI at the gateway with one of:
openshell --gateway vm-dev status
openshell gateway select vm-dev # then plain `openshell <command>`
Override defaults via environment:
# custom port (fails fast if in use)
OPENSHELL_SERVER_PORT=18091 mise run gateway:vm
# custom CLI gateway name + namespace
OPENSHELL_VM_GATEWAY_NAME=vm-feature-a \
OPENSHELL_SANDBOX_NAMESPACE=vm-feature-a \
mise run gateway:vm
# custom sandbox image
OPENSHELL_SANDBOX_IMAGE=ghcr.io/example/sandbox:latest mise run gateway:vm
# custom bootstrap image for the VM runtime used to prepare/boot target images
OPENSHELL_VM_BOOTSTRAP_IMAGE=ghcr.io/example/bootstrap:latest mise run gateway:vm
Teardown:
rm -rf /tmp/openshell-vm-driver-$USER-vm-dev .cache/gateway-vm
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/vm-dev"
Manual equivalent
If you want to drive the launch yourself instead of using mise run gateway:vm (i.e. tasks/scripts/gateway-vm.sh):
# 1. Stage runtime artifacts + supervisor bundle into target/vm-runtime-compressed/
mise run vm:setup
mise run vm:supervisor # if openshell-sandbox.zst is not already present
# 2. Build both binaries with the staged artifacts embedded
OPENSHELL_VM_RUNTIME_COMPRESSED_DIR=$PWD/target/vm-runtime-compressed \
cargo build -p openshell-server -p openshell-driver-vm
# 3. macOS only: codesign the driver for Hypervisor.framework
codesign \
--entitlements crates/openshell-driver-vm/entitlements.plist \
--force -s - target/debug/openshell-driver-vm
# 4. Start the gateway with the VM driver
mkdir -p /tmp/openshell-vm-driver-$USER-vm-dev .cache/gateway-vm
cat > .cache/gateway-vm/gateway.toml <<EOF
[openshell]
version = 1
[openshell.gateway]
compute_drivers = ["vm"]
disable_tls = true
[openshell.drivers.vm]
default_image = "<compatible-image>"
grpc_endpoint = "http://host.containers.internal:18081"
driver_dir = "$PWD/target/debug"
state_dir = "/tmp/openshell-vm-driver-$USER-vm-dev"
EOF
target/debug/openshell-gateway \
--config .cache/gateway-vm/gateway.toml \
--drivers vm \
--disable-tls \
--db-url "sqlite:.cache/gateway-vm/gateway.db?mode=rwc" \
--port 18081
The gateway resolves openshell-driver-vm in this order: [openshell.drivers.vm].driver_dir, conventional install locations (~/.local/libexec/openshell, /usr/libexec/openshell, /usr/local/libexec/openshell, /usr/local/libexec), then a sibling of the gateway binary.
Gateway And Driver Configuration
Select the VM driver with --drivers vm, OPENSHELL_DRIVERS=vm, or compute_drivers = ["vm"] in [openshell.gateway]. Configure VM-specific settings in [openshell.drivers.vm].
| Configuration key | Default | Purpose |
|---|---|---|
grpc_endpoint |
empty | Required. URL the sandbox guest dials to reach the gateway. Use http://host.containers.internal:<port> (or host.docker.internal / host.openshell.internal) so traffic flows through gvproxy's host-loopback NAT (HostIP 192.168.127.254 → host 127.0.0.1). Loopback URLs like http://127.0.0.1:<port> are rewritten automatically by the driver. The bare gateway IP (192.168.127.1) only carries gvproxy's own services and will not reach host-bound ports. |
state_dir |
target/openshell-vm-driver |
Per-sandbox overlay disks, console logs, image cache, and private run/compute-driver.sock UDS. |
driver_dir |
unset | Override the directory searched for openshell-driver-vm. |
default_image |
OpenShell base image | Sandbox image used when a create request omits one. |
bootstrap_image |
unset | VM runtime image used as the immutable bootstrap root disk. Defaults to the sandbox image when unset. |
vcpus |
2 |
vCPUs per sandbox. |
mem_mib |
2048 |
Memory per sandbox, in MiB. |
overlay_disk_mib |
4096 |
Sparse writable overlay disk size per sandbox, in MiB. |
krun_log_level |
1 |
libkrun verbosity (0-5). |
guest_tls_ca |
unset | CA cert for the guest's mTLS client bundle. Required when grpc_endpoint uses https://. |
guest_tls_cert |
unset | Guest client certificate. |
guest_tls_key |
unset | Guest client private key. |
See openshell-gateway --help for the gateway process flag surface.
Verifying the gateway
The gateway is auto-registered by mise run gateway:vm. In another terminal:
./scripts/bin/openshell status
./scripts/bin/openshell sandbox create --name demo --from <compatible-image>
./scripts/bin/openshell sandbox connect demo
First sandbox takes 10–30 seconds to boot (image fetch/prepare/cache + libkrun + guest init). If --from is omitted, the VM driver uses the gateway's configured default sandbox image. Without either --from or --sandbox-image, VM sandbox creation fails. Subsequent creates reuse the prepared image cache and create only a sparse per-sandbox overlay.ext4 before boot.
CreateSandbox accepts the sandbox quickly and continues VM provisioning in the
background. The driver publishes platform events for image resolution, cache
hits/misses, layer pulls, rootfs preparation, overlay creation, and VM launcher
startup so the CLI can show progress through the existing sandbox watch stream.
The VM driver keeps two image caches. The bootstrap cache is a controlled
rootfs.ext4 used to boot the guest init and OpenShell supervisor. The prepared
image cache is used when the requested sandbox image differs from the bootstrap
image: the host downloads registry layers into a valid OCI layout, attaches that
payload to a temporary bootstrap VM, and guest init runs umoci raw unpack onto
Linux-owned ext4 storage. The resulting disk is cached under
<state-dir>/images/<cache-id>/rootfs.ext4 and attached read-only to later
sandboxes. Local Docker images are still exported as rootfs tar archives and
prepared inside the bootstrap VM. Set OPENSHELL_VM_IMAGE_PULL_CONCURRENCY to
tune registry layer download parallelism (default 4, maximum 16).
Both caches are scoped by source image identity and OpenShell version, so an
OpenShell upgrade builds a fresh guest rootfs instead of reusing one with an old
embedded supervisor.
Each sandbox gets its own sparse writable
<state-dir>/sandboxes/<id>/overlay.ext4. Guest init mounts overlayfs as /
with the prepared image rootfs as lowerdir when present, otherwise the bootstrap
rootfs is used directly. Writes to /sandbox and other mutable paths land in
the overlay while cached image disks remain unchanged. The overlay disk must be
large enough to hold the compressed payload, unpacked rootfs, and sandbox writes
during the first prepare.
The driver also writes the accepted DriverSandbox launch request to
<state-dir>/sandboxes/<id>/sandbox.pb. If the gateway restarts, it starts a
new VM driver process; that process scans the sandbox state directories,
restarts each persisted VM launcher, and preserves any existing overlay.ext4
instead of cloning a fresh overlay template. If a restart happened before the
overlay was created, the driver creates it during the start attempt.
Stop writes a marker in the sandbox state directory before terminating
the launcher and releasing host GPU and network allocations. It retains
sandbox.pb, overlay.ext4, and lifecycle-extension state. Startup registers
marked sandboxes without launching compute. Start removes the marker and uses
the normal persisted restore path with the existing overlay. Delete removes the
entire sandbox state directory, including a stop marker and overlay.
Logs and debugging
Raise log verbosity for both processes:
RUST_LOG=openshell_server=debug,openshell_driver_vm=debug \
mise run gateway:vm
The VM guest's serial console is appended to <state-dir>/<sandbox-id>/console.log. Sandbox IDs must match [A-Za-z0-9._-]{1,128} before the driver uses them in host paths. The gateway-owned compute-driver socket lives at <state-dir>/run/compute-driver.sock; OpenShell creates run/ with owner-only permissions and removes same-owner stale sockets. On clean shutdown, the gateway sends the managed driver SIGTERM, waits up to five seconds for it to flush telemetry and exit, then force-kills it if necessary and removes the socket. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit --allow-same-uid-peer development flag. TCP mode is disabled by default because it is unauthenticated; use --allow-unauthenticated-tcp --bind-address 127.0.0.1:50061 only for local development.
Host-side nftables rules
The VM driver creates a per-VM nftables table on the host (openshell_vm_vmtap_<id>) with three chains. These rules serve two purposes: NAT infrastructure (required for VM connectivity) and defense-in-depth host isolation. Primary security enforcement — proxy-only egress and bypass detection — is handled by the sandbox supervisor's own nftables rules inside the VM guest.
postrouting (NAT): Masquerades outbound VM traffic so it can be routed from the VM's private subnet to the external network. This chain handles forwarded traffic (VM → internet), not traffic destined for the host.
forward (defense-in-depth): Accepts all outbound traffic from the VM (security enforcement happens guest-side) and accepts established/related response traffic back to the VM. Drops unsolicited inbound connections to the VM from the broader network. This chain handles forwarded traffic only — packets transiting the host between the TAP interface and other interfaces.
input (defense-in-depth): Accepts traffic from the VM to the gateway port on the host. Drops all other traffic from the VM destined for the host itself. This limits what a compromised guest can reach on the host to the gateway service only.
The input and postrouting chains handle different traffic paths: input covers packets addressed to the host (VM → host), while postrouting covers packets the host is forwarding on behalf of the VM (VM → internet). A packet from the VM goes through one path or the other, never both.
All chains use policy accept, so non-TAP traffic is unaffected. Because nftables evaluates multiple base chains on the same hook independently, host firewalls interact with these rules as follows:
- Open host (no other firewall): Our chains are the only filter. The defense-in-depth drop rules block unsolicited inbound and non-gateway host access. Non-TAP traffic passes through.
- Restrictive host firewall (e.g. firewalld): The host firewall's chains may additionally drop TAP traffic that our chains accept. A
dropverdict from any chain is final — ouracceptcannot override it. If VM connectivity fails, verify that the host firewall allows forwarding and input forvmtap-*interfaces.
Each table is created atomically via nft -f on VM start and torn down atomically via nft delete table when the VM is destroyed.
Prerequisites
- macOS on Apple Silicon, or Linux on aarch64/x86_64 with KVM
- Rust toolchain
- e2fsprogs (
mke2fsormkfs.ext4, plusdebugfs) for root and overlay disk image creation and QEMU environment injection - Guest-supervisor cross-compile toolchain (needed on macOS, and on Linux when host arch ≠ guest arch):
- Matching rustup target:
rustup target add aarch64-unknown-linux-gnu(orx86_64-unknown-linux-gnufor an amd64 guest) cargo install --locked cargo-zigbuildandbrew install zig(or distro equivalent).vm:supervisorusescargo zigbuildto cross-compile the in-VMopenshell-sandboxsupervisor binary.
- Matching rustup target:
- mise task runner
- Docker or Podman socket on the local CLI/gateway host when using
openshell sandbox create --from ./Dockerfileor--from ./dir; the CLI builds the image and the VM driver exports it via the local container engine. Docker is tried first; if unavailable, the driver falls back to the Podman socket. On Linux, enable the Podman API socket withsystemctl --user start podman.socket ghCLI (used bymise run vm:setupto download pre-built runtime artifacts)
Releases
openshell-driver-vm is published as a normal OpenShell release artifact:
- development builds: the rolling
devrelease - tagged builds: the corresponding
v*release - runtime tarballs: the rolling
vm-runtimerelease, rebuilt on demand byrelease-vm-kernel.yml
On Debian-family Linux amd64 and arm64 systems, install.sh installs the
Debian package from the selected OPENSHELL_VERSION release tag. That package
includes openshell-gateway and openshell-driver-vm, but leaves
OPENSHELL_DRIVERS unset so the gateway uses its normal runtime
auto-detection. Set OPENSHELL_DRIVERS=vm to force the VM driver.
On RPM-family Linux x86_64 and aarch64 systems, install.sh installs the
openshell and openshell-gateway RPM packages from the selected release tag.
The RPM gateway package is configured for the Podman driver.
On Apple Silicon macOS, install.sh stages the generated openshell.rb
formula from the selected release in the nvidia/openshell Homebrew tap.
Homebrew installs openshell, openshell-gateway, and
openshell-driver-vm, ad-hoc signs the driver with the Hypervisor entitlement
in post_install, and owns the brew services gateway lifecycle. The service
also leaves OPENSHELL_DRIVERS unset so driver choice remains automatic unless
the user explicitly overrides it.
TODOs
- The gateway still configures the driver via CLI args; this will move to a gRPC bootstrap call so the driver interface is uniform across backends. See the
TODO(driver-abstraction)notes incrates/openshell-server/src/lib.rsandcrates/openshell-server/src/compute/vm.rs. - macOS local builds are codesigned by
tasks/scripts/gateway-vm.sh; the generated Homebrew formula signs the release tarball driver for local installs.