Skip to content

Commit 74eb820

Browse files
committed
fix(podman): harden sandbox callbacks
Deliver per-sandbox JWTs through Podman secrets so containerized gateways do not leak gateway-local paths to the host Podman API. Add protected, bindability-checked callback listeners for host Podman gateways, including rootless pasta loopback binds discovered from local interfaces. Default RPM gateways to loopback, expose enable_auto_callback_listener for opt-out, and improve containerized socket diagnostics. Signed-off-by: Adam Miller <admiller@redhat.com>
1 parent 45060f4 commit 74eb820

20 files changed

Lines changed: 789 additions & 122 deletions

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ metrics = "0.24"
6464
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
6565

6666
# Unix/Process
67-
nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term"] }
67+
nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term", "net"] }
6868
rustix = { version = "1.1", features = ["process"] }
6969

7070
# Serialization

architecture/compute-runtimes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ when a sandbox create request asks for GPU resources.
3131
| Runtime | Best fit | Sandbox boundary | Notes |
3232
|---|---|---|---|
3333
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
34-
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API, OCI image volumes, and CDI GPU devices when available. |
34+
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API, OCI image volumes, CDI GPU devices when available, Podman secrets for sandbox JWTs, and bindability-checked callback listeners for direct-host loopback gateway binds. Bridge-mode gateways can listen on the Podman bridge IP when bindable; rootless pasta can add a protected non-loopback local-interface listener when TLS/auth are enabled. Containerized gateways skip host listener discovery and require explicit port publishing. |
3535
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
3636
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. |
3737
| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a non-reserved custom `compute_drivers = ["<name>"]` entry with `[openshell.drivers.<name>].socket_path`, or at launch time by pairing `--drivers <name>` with `--compute-driver-socket=<path>`. Reserved built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot be used as unmanaged socket endpoints. The gateway connects to a UDS the operator already provisioned, runs `GetCapabilities`, logs the advertised `driver_name`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. |

crates/openshell-driver-podman/NETWORKING.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,29 @@ if config.grpc_endpoint.is_empty() {
255255
The bridge gateway IP is not a stable substitute in rootless mode because it
256256
can live inside the user namespace rather than on the host.
257257

258+
### Rootless pasta callback listener
259+
260+
When the gateway binds only to loopback, host-local clients can reach
261+
`127.0.0.1:<port>`, but sandbox containers do not share the host loopback
262+
namespace. In rootless Podman with pasta, `host.containers.internal` reaches the
263+
host through pasta's forwarding path and may not connect to a listener bound
264+
only to `127.0.0.1`.
265+
266+
For direct host gateways, the Podman driver detects rootless pasta from Podman
267+
system info (`rootless=true` and `rootlessNetworkCmd=pasta`). If the gateway
268+
bind address is loopback-only, TLS/auth are active, and
269+
auto-discovery is enabled, the driver adds a second callback listener on a
270+
bindable non-loopback local interface address. Candidate addresses come from
271+
local interface enumeration via `getifaddrs`; the driver does not probe an
272+
external IP to find this address. The supervisor endpoint remains
273+
`host.containers.internal:<port>`.
274+
275+
Set `enable_auto_callback_listener = false` in `[openshell.drivers.podman]` or
276+
`OPENSHELL_PODMAN_ENABLE_AUTO_CALLBACK_LISTENER=false` to disable the automatic
277+
listener. Containerized gateways skip host listener discovery because their
278+
network namespace is not the host namespace; publish the gateway container port
279+
on an address sandbox containers can reach.
280+
258281
### Layer 3 Inner Sandbox Network Namespace
259282

260283
Inside the container, the supervisor creates another network namespace for the
@@ -391,7 +414,10 @@ Gateway
391414

392415
The gateway binds to `127.0.0.1:17670` by default in the RPM packaging. Client
393416
certificates are auto-generated by `openshell-gateway generate-certs` on first
394-
start and bind-mounted into sandbox containers by the Podman driver.
417+
start and bind-mounted into sandbox containers by the Podman driver. In
418+
rootless pasta mode, the protected callback listener described above allows the
419+
supervisor to keep using `host.containers.internal:<port>` while the primary
420+
listener remains loopback-only.
395421

396422
## Differences from the Kubernetes Driver
397423

@@ -406,7 +432,7 @@ start and bind-mounted into sandbox containers by the Podman driver.
406432
| DNS | Kubernetes CoreDNS | Podman bridge DNS through aardvark-dns when DNS is enabled. |
407433
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | nftables inside inner sandbox netns plus supervisor policy. |
408434
| Supervisor delivery | Kubernetes driver managed pod image or template | OCI image volume mount. |
409-
| Secrets | Kubernetes Secret volume and env vars | Mounted TLS client materials from a Podman secret. |
435+
| Secrets | Kubernetes Secret volume and env vars | Per-sandbox JWT via Podman secret; TLS client materials from configured host files. |
410436

411437
Both drivers use the same reverse gRPC relay for SSH transport. The most
412438
important Podman-specific difference is network reachability: in rootless

crates/openshell-driver-podman/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ mount types:
6868
pulls the image during provisioning using the sandbox image pull policy.
6969

7070
Host bind mounts are disabled by default because they expose gateway host paths
71-
to sandbox requests. The driver still uses internal bind mounts for
72-
OpenShell-owned token and TLS material.
71+
to sandbox requests. The driver still uses internal bind mounts for configured
72+
TLS material; per-sandbox gateway JWTs are delivered through Podman secrets.
7373

7474
Podman `bind` mounts accept `source`, `target`, and optional `read_only`.
7575
User-supplied bind and volume mounts are read-only by default; set
@@ -214,6 +214,10 @@ Key points:
214214
host. Linux defaults to Podman's `host-gateway` resolver. macOS Podman
215215
machine defaults to gvproxy's host-loopback IP, `192.168.127.254`, because
216216
stale Podman machines may fail to resolve `host-gateway`.
217+
- Callback listener: direct host gateways that bind to loopback can add a
218+
protected non-loopback listener for rootless pasta callbacks when TLS/auth are
219+
enabled. Disable this with `enable_auto_callback_listener = false`. Gateway
220+
containers skip host listener discovery and require explicit port publishing.
217221
- nsenter: the supervisor uses `nsenter --net=` instead of `ip netns exec` for
218222
namespace operations, avoiding the sysfs remount path that fails in rootless
219223
containers.
@@ -336,6 +340,7 @@ Podman resources after out-of-band container removal or label drift.
336340
| `OPENSHELL_GATEWAY_PORT` | `--gateway-port` | `17670` | Gateway port used for endpoint auto-detection by the standalone binary. |
337341
| `OPENSHELL_NETWORK_NAME` | `--network-name` | `openshell` | Podman bridge network name. |
338342
| `OPENSHELL_PODMAN_HOST_GATEWAY_IP` | `--host-gateway-ip` | empty on Linux, `192.168.127.254` on macOS | Host gateway IP used for sandbox host aliases. Empty uses Podman's `host-gateway` resolver. |
343+
| `OPENSHELL_PODMAN_ENABLE_AUTO_CALLBACK_LISTENER` | none | `true` | Enable the protected non-loopback callback listener for rootless pasta when the gateway is loopback-bound and TLS/auth are enabled. |
339344
| `OPENSHELL_SANDBOX_SSH_SOCKET_PATH` | `--sandbox-ssh-socket-path` | `/run/openshell/ssh.sock` | Supervisor Unix socket path in `PodmanComputeConfig`. |
340345
| `OPENSHELL_STOP_TIMEOUT` | `--stop-timeout` | `10` | Container stop timeout in seconds. |
341346
| `OPENSHELL_SANDBOX_PIDS_LIMIT` | `--sandbox-pids-limit` | `2048` | Podman cgroup PID limit for sandbox containers. Set `0` to inherit Podman's runtime/default PID limit. |

crates/openshell-driver-podman/src/client.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ pub struct HostInfo {
245245
#[serde(default)]
246246
pub network_backend: String,
247247
#[serde(default)]
248+
pub rootless_network_cmd: String,
249+
#[serde(default)]
248250
pub security: SecurityInfo,
249251
}
250252

@@ -391,6 +393,23 @@ impl PodmanClient {
391393
}
392394
}
393395

396+
/// Perform a versioned HTTP request with a raw byte body (not JSON).
397+
async fn request_raw(
398+
&self,
399+
method: hyper::Method,
400+
path: &str,
401+
content_type: &str,
402+
body: Bytes,
403+
) -> Result<(hyper::StatusCode, Bytes), PodmanApiError> {
404+
let req = Self::build_request(
405+
method,
406+
&format!("/{API_VERSION}{path}"),
407+
Full::new(body),
408+
Some(content_type),
409+
);
410+
self.send_request(req, API_TIMEOUT).await
411+
}
412+
394413
/// POST a JSON body and ignore 409 Conflict (resource already exists).
395414
async fn create_ignore_conflict(&self, path: &str, body: &Value) -> Result<(), PodmanApiError> {
396415
match self
@@ -518,6 +537,63 @@ impl PodmanClient {
518537
.await
519538
}
520539

540+
// ── Secret operations ────────────────────────────────────────────────
541+
542+
/// Create a Podman secret with the given name and raw value.
543+
///
544+
/// Idempotent: if a secret with the same name already exists it is
545+
/// replaced (delete + recreate) so the value is always up-to-date.
546+
pub async fn create_secret(&self, name: &str, value: &[u8]) -> Result<(), PodmanApiError> {
547+
validate_name(name)?;
548+
let encoded_name = url_encode(name);
549+
let path = format!("/libpod/secrets/create?name={encoded_name}");
550+
let (status, bytes) = self
551+
.request_raw(
552+
hyper::Method::POST,
553+
&path,
554+
"application/octet-stream",
555+
Bytes::copy_from_slice(value),
556+
)
557+
.await?;
558+
559+
match status.as_u16() {
560+
200 | 201 => Ok(()),
561+
409 => {
562+
self.remove_secret(name).await?;
563+
let (status2, bytes2) = self
564+
.request_raw(
565+
hyper::Method::POST,
566+
&path,
567+
"application/octet-stream",
568+
Bytes::copy_from_slice(value),
569+
)
570+
.await?;
571+
if status2.is_success() {
572+
Ok(())
573+
} else {
574+
Err(error_from_response(status2.as_u16(), &bytes2))
575+
}
576+
}
577+
_ => Err(error_from_response(status.as_u16(), &bytes)),
578+
}
579+
}
580+
581+
/// Remove a Podman secret by name. Idempotent (not-found is ignored).
582+
pub async fn remove_secret(&self, name: &str) -> Result<(), PodmanApiError> {
583+
validate_name(name)?;
584+
match self
585+
.request_ok(
586+
hyper::Method::DELETE,
587+
&format!("/libpod/secrets/{name}"),
588+
None,
589+
)
590+
.await
591+
{
592+
Ok(()) | Err(PodmanApiError::NotFound(_)) => Ok(()),
593+
Err(e) => Err(e),
594+
}
595+
}
596+
521597
// ── Network operations ───────────────────────────────────────────────
522598

523599
/// Create a bridge network with DNS enabled. Idempotent.

crates/openshell-driver-podman/src/config.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use openshell_core::config::{DEFAULT_STOP_TIMEOUT_SECS, DEFAULT_SUPERVISOR_IMAGE};
5-
use std::net::IpAddr;
5+
use std::net::{IpAddr, SocketAddr};
66
use std::path::PathBuf;
77
use std::str::FromStr;
88

@@ -68,6 +68,7 @@ impl FromStr for ImagePullPolicy {
6868

6969
#[derive(Clone, serde::Serialize, serde::Deserialize)]
7070
#[serde(default, deny_unknown_fields)]
71+
#[allow(clippy::struct_excessive_bools)]
7172
pub struct PodmanComputeConfig {
7273
/// Path to the Podman API Unix socket.
7374
/// Default: `$XDG_RUNTIME_DIR/podman/podman.sock` (Linux),
@@ -89,6 +90,15 @@ pub struct PodmanComputeConfig {
8990
/// so the correct port is used even when `--port` differs from the
9091
/// default. Defaults to [`openshell_core::config::DEFAULT_SERVER_PORT`].
9192
pub gateway_port: u16,
93+
/// Full gateway bind address supplied by the gateway process at runtime.
94+
#[serde(skip)]
95+
pub gateway_bind_address: Option<SocketAddr>,
96+
/// Whether the gateway has TLS enabled. Supplied by the gateway process.
97+
#[serde(skip)]
98+
pub gateway_tls_enabled: bool,
99+
/// Whether gateway callbacks are protected by an auth mechanism.
100+
#[serde(skip)]
101+
pub gateway_callback_auth_enabled: bool,
92102
/// Unix socket path the in-container supervisor bridges relay traffic to.
93103
pub sandbox_ssh_socket_path: String,
94104
/// Name of the Podman bridge network.
@@ -134,10 +144,18 @@ pub struct PodmanComputeConfig {
134144
/// Set to `0` to disable health checks entirely.
135145
/// Defaults to [`DEFAULT_HEALTH_CHECK_INTERVAL_SECS`] (10 seconds).
136146
pub health_check_interval_secs: u64,
147+
/// Automatically add a non-loopback callback listener for rootless Podman
148+
/// pasta when the primary gateway bind is loopback-only and TLS/auth are active.
149+
#[serde(default = "default_true")]
150+
pub enable_auto_callback_listener: bool,
137151
}
138152

139153
pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10;
140154

155+
fn default_true() -> bool {
156+
true
157+
}
158+
141159
impl PodmanComputeConfig {
142160
/// Returns `true` when all three TLS paths are configured.
143161
#[must_use]
@@ -251,6 +269,9 @@ impl Default for PodmanComputeConfig {
251269
image_pull_policy: ImagePullPolicy::default(),
252270
grpc_endpoint: String::new(),
253271
gateway_port: openshell_core::config::DEFAULT_SERVER_PORT,
272+
gateway_bind_address: None,
273+
gateway_tls_enabled: false,
274+
gateway_callback_auth_enabled: false,
254275
sandbox_ssh_socket_path: "/run/openshell/ssh.sock".to_string(),
255276
network_name: DEFAULT_NETWORK_NAME.to_string(),
256277
host_gateway_ip: Self::default_host_gateway_ip(),
@@ -262,6 +283,7 @@ impl Default for PodmanComputeConfig {
262283
sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT,
263284
enable_bind_mounts: false,
264285
health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS,
286+
enable_auto_callback_listener: true,
265287
}
266288
}
267289
}
@@ -274,6 +296,12 @@ impl std::fmt::Debug for PodmanComputeConfig {
274296
.field("image_pull_policy", &self.image_pull_policy.as_str())
275297
.field("grpc_endpoint", &self.grpc_endpoint)
276298
.field("gateway_port", &self.gateway_port)
299+
.field("gateway_bind_address", &self.gateway_bind_address)
300+
.field("gateway_tls_enabled", &self.gateway_tls_enabled)
301+
.field(
302+
"gateway_callback_auth_enabled",
303+
&self.gateway_callback_auth_enabled,
304+
)
277305
.field("sandbox_ssh_socket_path", &self.sandbox_ssh_socket_path)
278306
.field("network_name", &self.network_name)
279307
.field("host_gateway_ip", &self.host_gateway_ip)
@@ -288,6 +316,10 @@ impl std::fmt::Debug for PodmanComputeConfig {
288316
"health_check_interval_secs",
289317
&self.health_check_interval_secs,
290318
)
319+
.field(
320+
"enable_auto_callback_listener",
321+
&self.enable_auto_callback_listener,
322+
)
291323
.finish()
292324
}
293325
}

0 commit comments

Comments
 (0)