Skip to content

Commit 6b45b14

Browse files
committed
fix(podman): deliver sandbox JWTs as secrets
Signed-off-by: Adam Miller <admiller@redhat.com>
1 parent 45060f4 commit 6b45b14

5 files changed

Lines changed: 177 additions & 86 deletions

File tree

crates/openshell-driver-podman/NETWORKING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ start and bind-mounted into sandbox containers by the Podman driver.
406406
| DNS | Kubernetes CoreDNS | Podman bridge DNS through aardvark-dns when DNS is enabled. |
407407
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | nftables inside inner sandbox netns plus supervisor policy. |
408408
| 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. |
409+
| Secrets | Kubernetes Secret volume and env vars | Per-sandbox JWT via Podman secret; TLS client materials from configured host files. |
410410

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

crates/openshell-driver-podman/README.md

Lines changed: 2 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

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,23 @@ impl PodmanClient {
391391
}
392392
}
393393

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

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

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

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

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ const CONTAINER_PREFIX: &str = "openshell-sandbox-";
5151
/// Volume name prefix.
5252
const VOLUME_PREFIX: &str = "openshell-sandbox-";
5353

54+
/// Secret name prefix for per-sandbox gateway JWTs.
55+
const TOKEN_SECRET_PREFIX: &str = "openshell-token-";
56+
5457
/// Container-side mount paths for client TLS materials and the sandbox token.
5558
const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH;
5659
const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH;
@@ -149,6 +152,12 @@ pub fn volume_name(sandbox_id: &str) -> String {
149152
format!("{VOLUME_PREFIX}{sandbox_id}-workspace")
150153
}
151154

155+
/// Build the per-sandbox Podman secret name for the gateway JWT.
156+
#[must_use]
157+
pub fn token_secret_name(sandbox_id: &str) -> String {
158+
format!("{TOKEN_SECRET_PREFIX}{sandbox_id}")
159+
}
160+
152161
/// Truncate a container ID to 12 characters (standard short form).
153162
#[must_use]
154163
pub fn short_id(id: &str) -> String {
@@ -187,6 +196,8 @@ struct ContainerSpec {
187196
/// environment-variable injection, distinct from `secrets` which only
188197
/// handles file-mounted secrets under `/run/secrets/`.
189198
secret_env: BTreeMap<String, String>,
199+
/// File-mounted Podman secrets.
200+
secrets: Vec<SecretMount>,
190201
stop_timeout: u32,
191202
/// Extra /etc/hosts entries. Used to inject `host.containers.internal`
192203
/// via Podman's `host-gateway` magic so sandbox containers can reach
@@ -271,6 +282,15 @@ struct HealthConfig {
271282
start_period: u64,
272283
}
273284

285+
#[derive(Serialize)]
286+
struct SecretMount {
287+
source: String,
288+
target: String,
289+
uid: u32,
290+
gid: u32,
291+
mode: u32,
292+
}
293+
274294
#[derive(Serialize)]
275295
struct ResourceLimits {
276296
cpu: CpuLimits,
@@ -763,17 +783,17 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi
763783
pub fn build_container_spec_with_token(
764784
sandbox: &DriverSandbox,
765785
config: &PodmanComputeConfig,
766-
token_host_path: Option<&Path>,
786+
token_secret_name: Option<&str>,
767787
) -> Value {
768-
try_build_container_spec_with_token(sandbox, config, token_host_path)
788+
try_build_container_spec_with_token(sandbox, config, token_secret_name)
769789
.expect("container spec should be valid")
770790
}
771791

772792
#[cfg(test)]
773793
pub fn try_build_container_spec_with_token(
774794
sandbox: &DriverSandbox,
775795
config: &PodmanComputeConfig,
776-
token_host_path: Option<&Path>,
796+
token_secret_name: Option<&str>,
777797
) -> Result<Value, ComputeDriverError> {
778798
let driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?;
779799
let gpu_requirements = sandbox
@@ -791,13 +811,13 @@ pub fn try_build_container_spec_with_token(
791811
} else {
792812
None
793813
};
794-
build_container_spec_with_token_and_gpu_devices(sandbox, config, token_host_path, cdi_devices)
814+
build_container_spec_with_token_and_gpu_devices(sandbox, config, token_secret_name, cdi_devices)
795815
}
796816

797817
pub fn build_container_spec_with_token_and_gpu_devices(
798818
sandbox: &DriverSandbox,
799819
config: &PodmanComputeConfig,
800-
token_host_path: Option<&Path>,
820+
token_secret_name: Option<&str>,
801821
gpu_device_ids: Option<&[String]>,
802822
) -> Result<Value, ComputeDriverError> {
803823
let image = resolve_image(sandbox, config);
@@ -809,6 +829,16 @@ pub fn build_container_spec_with_token_and_gpu_devices(
809829
let resource_limits = build_resource_limits(sandbox, config);
810830
let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)
811831
.map_err(ComputeDriverError::InvalidArgument)?;
832+
if sandbox
833+
.spec
834+
.as_ref()
835+
.is_some_and(|spec| !spec.sandbox_token.is_empty())
836+
&& token_secret_name.is_none()
837+
{
838+
return Err(ComputeDriverError::Precondition(
839+
"podman sandbox token secret is required when sandbox token is set".to_string(),
840+
));
841+
}
812842
let devices = gpu_device_ids.map(|device_ids| {
813843
device_ids
814844
.iter()
@@ -953,6 +983,15 @@ pub fn build_container_spec_with_token_and_gpu_devices(
953983
},
954984
resource_limits,
955985
secret_env: BTreeMap::new(),
986+
secrets: token_secret_name.map_or_else(Vec::new, |source| {
987+
vec![SecretMount {
988+
source: source.to_string(),
989+
target: SANDBOX_TOKEN_MOUNT_PATH.into(),
990+
uid: 0,
991+
gid: 0,
992+
mode: 0o400,
993+
}]
994+
}),
956995
stop_timeout: config.stop_timeout_secs,
957996
// Inject stable host aliases into /etc/hosts so sandbox containers can
958997
// reach services on the host. `host.openshell.internal` is the driver-
@@ -1012,18 +1051,6 @@ pub fn build_container_spec_with_token_and_gpu_devices(
10121051
options: ro,
10131052
});
10141053
}
1015-
if let Some(path) = token_host_path {
1016-
let mut ro = vec!["ro".into(), "rbind".into()];
1017-
if is_selinux_enabled() {
1018-
ro.push("z".into());
1019-
}
1020-
m.push(Mount {
1021-
kind: "bind".into(),
1022-
source: path.display().to_string(),
1023-
destination: SANDBOX_TOKEN_MOUNT_PATH.into(),
1024-
options: ro,
1025-
});
1026-
}
10271054
m.extend(user_mounts.mounts);
10281055
m
10291056
},
@@ -2215,7 +2242,7 @@ mod tests {
22152242
}
22162243

22172244
#[test]
2218-
fn container_spec_uses_token_file_mount_without_raw_token_env() {
2245+
fn container_spec_uses_token_secret_mount_without_raw_token_env() {
22192246
use openshell_core::proto::compute::v1::DriverSandboxSpec;
22202247

22212248
let mut sandbox = test_sandbox("token-id", "token-name");
@@ -2224,9 +2251,9 @@ mod tests {
22242251
..Default::default()
22252252
});
22262253
let config = test_config();
2227-
let token_path = Path::new("/host/token.jwt");
2254+
let secret_name = token_secret_name(&sandbox.id);
22282255

2229-
let spec = build_container_spec_with_token(&sandbox, &config, Some(token_path));
2256+
let spec = build_container_spec_with_token(&sandbox, &config, Some(&secret_name));
22302257

22312258
let env_map = spec["env"].as_object().expect("env should be an object");
22322259
assert_eq!(
@@ -2241,14 +2268,22 @@ mod tests {
22412268
.and_then(|v| v.as_str()),
22422269
Some("/etc/openshell/auth/sandbox.jwt")
22432270
);
2271+
let secrets = spec["secrets"]
2272+
.as_array()
2273+
.expect("secrets should be an array");
2274+
assert!(secrets.iter().any(|secret| {
2275+
secret["source"].as_str() == Some(secret_name.as_str())
2276+
&& secret["target"].as_str() == Some("/etc/openshell/auth/sandbox.jwt")
2277+
&& secret["mode"].as_u64() == Some(0o400)
2278+
}));
22442279
let mounts = spec["mounts"]
22452280
.as_array()
22462281
.expect("mounts should be an array");
2247-
assert!(mounts.iter().any(|m| {
2248-
m["type"].as_str() == Some("bind")
2249-
&& m["source"].as_str() == Some("/host/token.jwt")
2250-
&& m["destination"].as_str() == Some("/etc/openshell/auth/sandbox.jwt")
2251-
}));
2282+
assert!(
2283+
!mounts
2284+
.iter()
2285+
.any(|m| { m["destination"].as_str() == Some("/etc/openshell/auth/sandbox.jwt") })
2286+
);
22522287
}
22532288

22542289
#[test]

0 commit comments

Comments
 (0)