Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

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

12 changes: 12 additions & 0 deletions crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-p
/// mount is used rather than a driver secret.
pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem";

/// Container-side mount path for an operator-supplied additional destination
/// CA bundle.
///
/// Drivers with an `additional_ca_bundle` operator setting bind-mount the
/// host PEM file here (read-only) and pass the path on the supervisor's argv
/// via `--additional-ca-bundle`. The supervisor folds it into its upstream
/// TLS trust store alongside the system CA bundle, for direct (non-proxied)
/// inspected HTTPS egress to destinations signed by a private CA — unlike
/// [`PROXY_CA_MOUNT_PATH`], this applies regardless of whether a corporate
/// forward proxy is configured.
pub const ADDITIONAL_CA_MOUNT_PATH: &str = "/etc/openshell/tls/additional/ca-bundle.pem";

/// A validated corporate upstream-proxy address.
///
/// Produced by [`parse_upstream_proxy_url`], which is the single source of
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ url = { workspace = true }
openshell-otel-test-support = { path = "../openshell-otel-test-support" }
opentelemetry_sdk = { workspace = true, features = ["testing"] }
prost-types = { workspace = true }
rcgen = { workspace = true }
temp-env = "0.3"
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }
Expand Down
92 changes: 92 additions & 0 deletions crates/openshell-driver-podman/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,23 @@ pub struct PodmanComputeConfig {
/// and upstream verification. Only meaningful with `https_proxy` set; the
/// bundle must exist and contain at least one certificate.
pub proxy_ca_bundle: Option<String>,
/// Path (on the gateway host) to a PEM CA bundle trusted for direct
/// (non-proxied), policy-inspected HTTPS egress to destinations signed by
/// a private CA.
///
/// Unlike [`proxy_ca_bundle`](Self::proxy_ca_bundle), this applies
/// regardless of whether `https_proxy` is configured: it addresses
/// supervisors that run as their own container, separate from the
/// workload image, and therefore cannot inherit a private CA baked into
/// the workload's system trust store. The gateway bind-mounts this file
/// read-only into the supervisor container (at
/// [`ADDITIONAL_CA_MOUNT_PATH`](openshell_core::driver_utils::ADDITIONAL_CA_MOUNT_PATH))
/// and passes its path via `--additional-ca-bundle`. The supervisor folds
/// it into its upstream TLS trust store alongside (not instead of) the
/// system CA bundle. The bundle must exist and contain at least one
/// certificate; this is validated on the gateway host at sandbox-create
/// time, not deferred until the supervisor container starts.
pub additional_ca_bundle: Option<String>,
/// User namespace mode for sandbox containers (e.g. `auto`, `private`).
/// When unset, containers use the default user namespace.
pub userns: Option<String>,
Expand Down Expand Up @@ -332,6 +349,21 @@ impl PodmanComputeConfig {
));
}
}
// additional_ca_bundle is independent of the corporate proxy setting
// above: it applies to direct, inspected egress and has no
// https_proxy dependency. Read and validate the actual host file
// here (this path is not yet mounted into any container), rather
// than only checking it is non-empty: an operator with a mistyped
// path or a certificate-free bundle should see a config-validation
// error when the driver starts, not a failed/crash-looping sandbox
// once the supervisor container starts and tries to read it.
if let Some(path) = self.additional_ca_bundle.as_deref() {
openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file(
path,
"additional_ca_bundle",
)
.map_err(crate::client::PodmanApiError::InvalidInput)?;
}
Ok(())
}

Expand Down Expand Up @@ -519,6 +551,7 @@ impl Default for PodmanComputeConfig {
proxy_auth_allow_insecure: None,
proxy_connect_by_hostname: None,
proxy_ca_bundle: None,
additional_ca_bundle: None,
userns: None,
uidmap: Vec::new(),
gidmap: Vec::new(),
Expand Down Expand Up @@ -832,6 +865,65 @@ mod tests {
assert!(err.to_string().contains("proxy_ca_bundle"), "{err}");
}

#[test]
fn validate_configuration_accepts_additional_ca_bundle_without_proxy() {
// #3781: unlike proxy_ca_bundle, this must work with no corporate
// proxy configured at all.
let directory = tempfile::tempdir().unwrap();
let ca_path = directory.path().join("additional-ca.pem");
std::fs::write(&ca_path, generate_test_ca_pem()).unwrap();

let cfg = PodmanComputeConfig {
additional_ca_bundle: Some(ca_path.to_str().unwrap().to_string()),
..PodmanComputeConfig::default()
};
assert!(cfg.validate_proxy_config().is_ok());
assert!(cfg.https_proxy.is_none());
}

#[test]
fn validate_configuration_rejects_empty_additional_ca_bundle() {
let cfg = PodmanComputeConfig {
additional_ca_bundle: Some(" ".to_string()),
..PodmanComputeConfig::default()
};
let err = cfg.validate_proxy_config().unwrap_err();
assert!(err.to_string().contains("additional_ca_bundle"), "{err}");
}

#[test]
fn validate_configuration_rejects_nonexistent_additional_ca_bundle() {
// #3781 fix requirement: an operator with a mistyped path must see
// this at config-validation time, not a failed sandbox later.
let cfg = PodmanComputeConfig {
additional_ca_bundle: Some("/nonexistent/additional-ca.pem".to_string()),
..PodmanComputeConfig::default()
};
let err = cfg.validate_proxy_config().unwrap_err();
assert!(err.to_string().contains("additional_ca_bundle"), "{err}");
}

#[test]
fn validate_configuration_rejects_certificate_free_additional_ca_bundle() {
let directory = tempfile::tempdir().unwrap();
let ca_path = directory.path().join("not-a-cert.pem");
std::fs::write(&ca_path, "not a certificate\n").unwrap();

let cfg = PodmanComputeConfig {
additional_ca_bundle: Some(ca_path.to_str().unwrap().to_string()),
..PodmanComputeConfig::default()
};
let err = cfg.validate_proxy_config().unwrap_err();
assert!(err.to_string().contains("additional_ca_bundle"), "{err}");
}

/// Generate a real, valid self-signed CA certificate PEM for tests.
fn generate_test_ca_pem() -> String {
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(Vec::new()).unwrap();
params.self_signed(&key_pair).unwrap().pem()
}

#[test]
fn validate_proxy_config_rejects_url_components() {
for url in [
Expand Down
160 changes: 159 additions & 1 deletion crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str =
openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH;
const RESOLV_CONF_PATH: &str = "/etc/resolv.conf";
const PROXY_CA_MOUNT_PATH: &str = openshell_core::driver_utils::PROXY_CA_MOUNT_PATH;
const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_core::driver_utils::ADDITIONAL_CA_MOUNT_PATH;
const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str =
openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR;

Expand Down Expand Up @@ -612,6 +613,19 @@ fn upstream_proxy_cli_args(config: &PodmanComputeConfig) -> Vec<String> {
args
}

/// Build the additional-destination-CA command-line argument passed to the
/// supervisor. Independent of `upstream_proxy_cli_args`: this applies to
/// direct, inspected egress regardless of whether a corporate proxy is
/// configured.
fn additional_ca_cli_args(config: &PodmanComputeConfig) -> Vec<String> {
let mut args = Vec::new();
if config.additional_ca_bundle.is_some() {
args.push("--additional-ca-bundle".to_string());
args.push(ADDITIONAL_CA_MOUNT_PATH.to_string());
}
args
}

fn build_env(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
Expand Down Expand Up @@ -1272,6 +1286,7 @@ fn build_base_spec(
image_volumes.extend(user_mounts.image_volumes);
let mut command = vec!["--workdir".to_string(), image.workspace_root.clone()];
command.extend(upstream_proxy_cli_args(config));
command.extend(additional_ca_cli_args(config));

let container_spec = ContainerSpec {
name,
Expand Down Expand Up @@ -1438,6 +1453,24 @@ fn build_base_spec(
options: ro,
});
}
// Bind-mount an operator-supplied additional destination CA
// bundle read-only when configured. Independent of the corporate
// proxy CA above: this applies to direct, inspected egress. The
// supervisor reads it via the --additional-ca-bundle argv path
// (see additional_ca_cli_args) and folds it into its upstream
// trust store alongside the system bundle.
if let Some(ca_bundle) = &config.additional_ca_bundle {
Comment thread
TaylorMutch marked this conversation as resolved.
let mut ro = vec!["ro".into(), "rbind".into()];
if is_selinux_enabled() {
ro.push("z".into());
}
m.push(Mount {
kind: "bind".into(),
source: ca_bundle.clone(),
destination: ADDITIONAL_CA_MOUNT_PATH.into(),
options: ro,
});
}
if let Some(bin_path) = supervisor_bin_path {
let mut opts = vec!["ro".into(), "rbind".into()];
if is_selinux_enabled() {
Expand Down Expand Up @@ -1729,7 +1762,10 @@ pub fn build_isolation_specs(
fn trusted_mount(destination: &str) -> bool {
matches!(
destination,
TLS_CA_MOUNT_PATH | PROXY_CA_MOUNT_PATH | PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR
TLS_CA_MOUNT_PATH
| PROXY_CA_MOUNT_PATH
| ADDITIONAL_CA_MOUNT_PATH
| PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR
) || destination == openshell_core::container_paths::NETNS_MOUNT_ROOT
}

Expand Down Expand Up @@ -2870,6 +2906,128 @@ mod tests {
);
}

#[test]
fn container_spec_binds_additional_ca_bundle_and_passes_argv_without_proxy() {
// #3781: the additional CA must work for direct, inspected egress
// with no corporate proxy configured at all — unlike proxy_ca_bundle,
// which requires https_proxy.
let sandbox = test_sandbox("additional-ca-id", "additional-ca-name");
let mut config = test_config();
assert!(
config.https_proxy.is_none(),
"test must exercise the no-proxy case"
);
config.additional_ca_bundle = Some("/host/additional-ca.pem".to_string());

let spec = build_container_spec(&sandbox, &config);
let command = spec_command(&spec);

let idx = command
.iter()
.position(|a| a == "--additional-ca-bundle")
.expect("additional CA bundle flag present");
assert_eq!(
command.get(idx + 1).map(String::as_str),
Some("/etc/openshell/tls/additional/ca-bundle.pem")
);

let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
let ca_mount = mounts
.iter()
.find(|m| {
m["type"].as_str() == Some("bind")
&& m["destination"].as_str()
== Some("/etc/openshell/tls/additional/ca-bundle.pem")
})
.expect("additional CA bundle bind mount present");
assert_eq!(ca_mount["source"].as_str(), Some("/host/additional-ca.pem"));
assert!(
ca_mount["options"]
.as_array()
.expect("options array")
.iter()
.any(|o| o.as_str() == Some("ro")),
"additional CA bundle mount must be read-only"
);
}

#[test]
fn isolated_pair_mounts_additional_ca_bundle_only_on_supervisor() {
// build_isolation_specs() filters the base spec's mounts per role, so
// the base-spec test above does not prove the final supervisor keeps
// the CA the --additional-ca-bundle argv points at.
let sandbox = test_sandbox("additional-ca-pair", "additional-ca-pair");
let mut config = test_config();
config.additional_ca_bundle = Some("/host/additional-ca.pem".to_string());
let identity = openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new(
1000,
1000,
Vec::new(),
"default".into(),
"sha256:image".into(),
)
.unwrap();
let specs = build_isolation_specs(IsolationSpecInput {
sandbox: &sandbox,
config: &config,
token_secret: Some("jwt"),
resolver_secret: "resolver",
gpu_devices: None,
image: &ResolvedPodmanImage::new("sha256:image"),
supervisor_bin: None,
tls_secrets: None,
identity: &identity,
})
.unwrap();

let ca_mount = specs
.supervisor
.mounts
.iter()
.find(|mount| mount.destination == ADDITIONAL_CA_MOUNT_PATH)
.expect("supervisor keeps the additional CA bind mount");
assert_eq!(ca_mount.kind, "bind");
assert_eq!(ca_mount.source, "/host/additional-ca.pem");
assert!(ca_mount.options.contains(&"ro".to_string()));
let idx = specs
.supervisor
.command
.iter()
.position(|arg| arg == "--additional-ca-bundle")
.expect("supervisor argv carries the additional CA flag");
assert_eq!(
specs.supervisor.command.get(idx + 1).map(String::as_str),
Some(ADDITIONAL_CA_MOUNT_PATH)
);
assert!(
specs
.workload
.mounts
.iter()
.all(|mount| mount.destination != ADDITIONAL_CA_MOUNT_PATH),
"workload must not receive the additional CA mount"
);
}

#[test]
fn container_spec_omits_additional_ca_bundle_when_unconfigured() {
let sandbox = test_sandbox("test-id", "test-name");
let spec = build_container_spec(&sandbox, &test_config());
let command = spec_command(&spec);
assert!(
!command.iter().any(|a| a == "--additional-ca-bundle"),
"no additional-ca-bundle flag without operator config"
);
let mounts = spec["mounts"].as_array().expect("mounts array");
assert!(
!mounts.iter().any(|m| m["destination"].as_str()
== Some("/etc/openshell/tls/additional/ca-bundle.pem")),
"no additional CA bundle mount without operator config"
);
}

#[test]
fn container_spec_sandbox_env_cannot_influence_proxy_argv() {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
Expand Down
9 changes: 9 additions & 0 deletions crates/openshell-driver-podman/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ struct Args {
#[arg(long, env = "OPENSHELL_SANDBOX_PROXY_CA_BUNDLE")]
sandbox_proxy_ca_bundle: Option<String>,

/// Path (on the gateway host) to a PEM CA bundle trusted for direct
/// (non-proxied), policy-inspected HTTPS egress to destinations signed by
/// a private CA. Unlike `--sandbox-proxy-ca-bundle`, this applies
/// regardless of whether a corporate proxy is configured. Bind-mounted
/// read-only into the supervisor container.
#[arg(long, env = "OPENSHELL_SANDBOX_ADDITIONAL_CA_BUNDLE")]
sandbox_additional_ca_bundle: Option<String>,

/// User namespace mode for sandbox containers (e.g. `auto`).
/// When unset, containers use the default user namespace.
#[arg(long, env = "OPENSHELL_PODMAN_USERNS")]
Expand Down Expand Up @@ -241,6 +249,7 @@ async fn main() -> Result<()> {
proxy_auth_allow_insecure: args.sandbox_proxy_auth_allow_insecure,
proxy_connect_by_hostname: args.sandbox_proxy_connect_by_hostname,
proxy_ca_bundle: args.sandbox_proxy_ca_bundle,
additional_ca_bundle: args.sandbox_additional_ca_bundle,
userns: args.userns,
uidmap: args.uidmap,
gidmap: args.gidmap,
Expand Down
Loading
Loading