Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/openshell-driver-podman/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
thiserror = { workspace = true }
miette = { workspace = true }
tar = "0.4"
tempfile = "3"

[dev-dependencies]
prost-types = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ Podman resources after out-of-band container removal or label drift.
| `OPENSHELL_SANDBOX_PROXY_AUTH_FILE` | `--sandbox-proxy-auth-file` | unset | Path to a file containing the proxy credentials as `user:pass`. Staged as a root-only Podman secret so credentials never appear in config or container metadata. Requires the insecure-auth acknowledgement below. |
| `OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE` | `--sandbox-proxy-auth-allow-insecure` | unset | Explicit acknowledgement (`true`) that the credential is sent as cleartext Basic auth over the plain-TCP connection to the `http://` proxy. Required when the auth file is set; rejected when it is not. |
| `OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME` | `--sandbox-proxy-connect-by-hostname` | unset | Send the destination hostname in CONNECT requests instead of a validated IP. Last resort for proxies whose ACLs filter on hostnames: the proxy then resolves the name itself, so sandbox SSRF/`allowed_ips` validation no longer binds the connection. |
| `OPENSHELL_PODMAN_USERNS` | `--userns` | unset | User namespace mode for sandbox containers (e.g. `auto`). When unset, containers use the default user namespace. |

Through the gateway, the same settings are the `https_proxy`, `no_proxy`,
`proxy_auth_file`, `proxy_auth_allow_insecure`, and
Expand Down
26 changes: 26 additions & 0 deletions crates/openshell-driver-podman/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,32 @@ impl PodmanClient {
.await
}

/// Download a file from a container as a tar archive.
///
/// Calls `GET /libpod/containers/{name}/archive?path={path}` and returns
/// the raw tar bytes. The container does not need to be running.
pub async fn copy_from_container(
&self,
name: &str,
path: &str,
) -> Result<Bytes, PodmanApiError> {
validate_name(name)?;
let encoded_path = url_encode(path);
let (status, bytes) = self
.request(
hyper::Method::GET,
&format!("/libpod/containers/{name}/archive?path={encoded_path}"),
None,
API_TIMEOUT,
)
.await?;
if status.is_success() {
Ok(bytes)
} else {
Err(error_from_response(status.as_u16(), &bytes))
}
}

/// Inspect a container by name or ID.
pub async fn inspect_container(&self, name: &str) -> Result<ContainerInspect, PodmanApiError> {
validate_name(name)?;
Expand Down
5 changes: 5 additions & 0 deletions crates/openshell-driver-podman/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ pub struct PodmanComputeConfig {
/// pointing the gateway host at the corporate resolver so validated-IP
/// CONNECT works in split-horizon networks.
pub proxy_connect_by_hostname: Option<bool>,
/// User namespace mode for sandbox containers (e.g. `auto`).
/// When unset, containers use the default user namespace.
pub userns: Option<String>,
}

pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10;
Expand Down Expand Up @@ -380,6 +383,7 @@ impl Default for PodmanComputeConfig {
proxy_auth_file: None,
proxy_auth_allow_insecure: None,
proxy_connect_by_hostname: None,
userns: None,
}
}
}
Expand Down Expand Up @@ -412,6 +416,7 @@ impl std::fmt::Debug for PodmanComputeConfig {
.field("proxy_auth_file", &self.proxy_auth_file.is_some())
.field("proxy_auth_allow_insecure", &self.proxy_auth_allow_insecure)
.field("proxy_connect_by_hostname", &self.proxy_connect_by_hostname)
.field("userns", &self.userns)
.finish()
}
}
Expand Down
162 changes: 157 additions & 5 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ struct ContainerSpec {
/// Port mappings from host to container. Using `host_port=0` requests an
/// ephemeral port, readable back from the inspect response.
portmappings: Vec<PortMapping>,
/// User namespace mode override (e.g. `auto`).
#[serde(skip_serializing_if = "Option::is_none")]
userns: Option<UserNS>,
/// UID/GID mapping options. Required for `userns = "auto"` — the Podman
/// API needs `AutoUserNs: true` alongside the namespace mode.
#[serde(skip_serializing_if = "Option::is_none")]
idmappings: Option<IDMappings>,
}

/// A port mapping entry for the libpod `SpecGenerator`.
Expand Down Expand Up @@ -328,6 +335,17 @@ struct NetNS {
nsmode: String,
}

#[derive(Serialize)]
struct UserNS {
nsmode: String,
}

#[derive(Serialize)]
#[allow(non_snake_case)]
struct IDMappings {
AutoUserNs: bool,
}

#[derive(Serialize)]
struct NetworkAttachment {}

Expand Down Expand Up @@ -905,6 +923,7 @@ pub fn build_container_spec_with_token_and_gpu_devices(
image,
image,
"",
None,
)
}

Expand All @@ -916,6 +935,7 @@ pub fn build_container_spec_for_image(
requested_image: &str,
image_id: &str,
oci_user: &str,
supervisor_bin_path: Option<&Path>,
) -> Result<Value, ComputeDriverError> {
let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id);
let vol = volume_name(&sandbox.id);
Expand Down Expand Up @@ -957,11 +977,15 @@ pub fn build_container_spec_for_image(
}];
volumes.extend(user_mounts.volumes);

let mut image_volumes = vec![ImageVolume {
source: config.supervisor_image.clone(),
destination: SUPERVISOR_MOUNT_DIR.into(),
rw: false,
}];
let mut image_volumes = if supervisor_bin_path.is_some() {
Vec::new()
} else {
vec![ImageVolume {
source: config.supervisor_image.clone(),
destination: SUPERVISOR_MOUNT_DIR.into(),
rw: false,
}]
};
image_volumes.extend(user_mounts.image_volumes);

let container_spec = ContainerSpec {
Expand Down Expand Up @@ -1166,6 +1190,18 @@ pub fn build_container_spec_for_image(
options: ro,
});
}
if let Some(bin_path) = supervisor_bin_path {
let mut opts = vec!["ro".into(), "rbind".into()];
if is_selinux_enabled() {
opts.push("z".into());
}
m.push(Mount {
kind: "bind".into(),
source: bin_path.display().to_string(),
destination: SUPERVISOR_BINARY_PATH.into(),
options: opts,
});
}
m.extend(user_mounts.mounts);
m
},
Expand All @@ -1177,6 +1213,14 @@ pub fn build_container_spec_for_image(
container_port: openshell_core::config::DEFAULT_SSH_PORT,
protocol: "tcp".into(),
}],
userns: config.userns.as_deref().map(|mode| UserNS {
nsmode: mode.to_string(),
}),
idmappings: config
.userns
.as_deref()
.filter(|mode| mode.eq_ignore_ascii_case("auto"))
.map(|_| IDMappings { AutoUserNs: true }),
};

Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail"))
Expand Down Expand Up @@ -1379,6 +1423,7 @@ mod tests {
"registry.example/app:latest",
"sha256:immutable",
"app:staff",
None,
)
.unwrap();

Expand Down Expand Up @@ -2777,4 +2822,111 @@ mod tests {
.count();
assert_eq!(bind_count, 0, "no bind mounts without TLS config");
}

#[test]
fn container_spec_includes_userns_when_configured() {
let sandbox = test_sandbox("userns-id", "userns-name");
let mut config = test_config();
config.userns = Some("auto".to_string());
let spec = build_container_spec(&sandbox, &config);

let userns = &spec["userns"];
assert_eq!(userns["nsmode"].as_str(), Some("auto"));

let idmappings = &spec["idmappings"];
assert_eq!(
idmappings["AutoUserNs"].as_bool(),
Some(true),
"idmappings.AutoUserNs must be true for userns=auto"
);
}

#[test]
fn container_spec_omits_userns_when_unset() {
let sandbox = test_sandbox("no-userns-id", "no-userns-name");
let config = test_config();
let spec = build_container_spec(&sandbox, &config);

assert!(
spec.get("userns").is_none(),
"userns should not be set when unconfigured"
);
assert!(
spec.get("idmappings").is_none(),
"idmappings should not be set when userns is unconfigured"
);
}

#[test]
fn container_spec_uses_bind_mount_for_supervisor_when_path_provided() {
let sandbox = test_sandbox("bind-sv-id", "bind-sv-name");
let config = test_config();
let image = resolve_image(&sandbox, &config);
let spec = build_container_spec_for_image(
&sandbox,
&config,
None,
None,
image,
image,
"",
Some(Path::new("/host/cache/openshell-sandbox")),
)
.unwrap();

let image_volumes = spec["image_volumes"]
.as_array()
.expect("image_volumes should be an array");
assert!(
!image_volumes
.iter()
.any(|v| v["destination"].as_str() == Some(SUPERVISOR_MOUNT_DIR)),
"supervisor image volume should not be present when bind path is provided"
);

let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
let sv_bind = mounts
.iter()
.find(|m| m["destination"].as_str() == Some(SUPERVISOR_BINARY_PATH));
assert!(
sv_bind.is_some(),
"supervisor bind mount should be present at {SUPERVISOR_BINARY_PATH}"
);
let sv_bind = sv_bind.unwrap();
assert_eq!(
sv_bind["source"].as_str(),
Some("/host/cache/openshell-sandbox")
);
assert_eq!(sv_bind["type"].as_str(), Some("bind"));
}

#[test]
fn container_spec_uses_image_volume_when_no_bind_path() {
let sandbox = test_sandbox("imgvol-id", "imgvol-name");
let config = test_config();
let spec = build_container_spec(&sandbox, &config);

let image_volumes = spec["image_volumes"]
.as_array()
.expect("image_volumes should be an array");
assert!(
image_volumes
.iter()
.any(|v| v["destination"].as_str() == Some(SUPERVISOR_MOUNT_DIR)),
"supervisor image volume should be present by default"
);

let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
assert!(
!mounts.iter().any(
|m| m["destination"].as_str() == Some(SUPERVISOR_BINARY_PATH)
&& m["type"].as_str() == Some("bind")
),
"supervisor bind mount should not be present by default"
);
}
}
Loading
Loading