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
10 changes: 10 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ handled by the inference interception path:
External inference endpoints that do not use `inference.local` are treated like
ordinary network traffic and must be allowed by policy.

In proxy-required networks, the supervisor honors `HTTPS_PROXY`/`HTTP_PROXY`/
`NO_PROXY` from its own environment: after policy and SSRF checks pass, it
chains the upstream dial through the corporate forward proxy with HTTP CONNECT
instead of connecting directly. `NO_PROXY` destinations, loopback, and
host-gateway aliases always dial directly. Only `http://` proxy URLs are
supported. Local DNS resolution and SSRF validation still run before the
proxied dial; the CONNECT target sent to the corporate proxy is the requested
hostname. The workload child's proxy variables are unaffected — they are
always rewritten to point at the local policy proxy.

## Credentials

Provider credentials are stored at the gateway and fetched by the supervisor at
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,14 @@ Podman resources after out-of-band container removal or label drift.
| `OPENSHELL_PODMAN_TLS_CA` | `--podman-tls-ca` | unset | Host path to the CA certificate mounted for sandbox mTLS. |
| `OPENSHELL_PODMAN_TLS_CERT` | `--podman-tls-cert` | unset | Host path to the client certificate mounted for sandbox mTLS. |
| `OPENSHELL_PODMAN_TLS_KEY` | `--podman-tls-key` | unset | Host path to the client private key mounted for sandbox mTLS. |
| `OPENSHELL_SANDBOX_HTTPS_PROXY` | `--sandbox-https-proxy` | unset | Corporate forward proxy URL injected into sandbox containers as `HTTPS_PROXY`. The in-container supervisor chains policy-approved upstream dials through it with HTTP CONNECT. Only `http://` proxy URLs are supported. |
| `OPENSHELL_SANDBOX_HTTP_PROXY` | `--sandbox-http-proxy` | unset | Corporate forward proxy URL injected as `HTTP_PROXY` for plain HTTP requests. |
| `OPENSHELL_SANDBOX_NO_PROXY` | `--sandbox-no-proxy` | unset | Comma-separated `NO_PROXY` list (hostnames, domain suffixes, IPs, CIDRs) dialed directly instead of through the corporate proxy. |

Through the gateway, the same settings are the `https_proxy`, `http_proxy`, and
`no_proxy` keys under `[openshell.drivers.podman]`; see
`docs/reference/gateway-config.mdx`. Per-sandbox environment values take
precedence over these operator defaults.

## Rootless-Specific Adaptations

Expand Down
94 changes: 94 additions & 0 deletions crates/openshell-driver-podman/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ pub struct PodmanComputeConfig {
/// Set to `0` to disable health checks entirely.
/// Defaults to [`DEFAULT_HEALTH_CHECK_INTERVAL_SECS`] (10 seconds).
pub health_check_interval_secs: u64,
/// Corporate forward proxy URL injected into sandbox containers as
/// `HTTPS_PROXY`/`https_proxy` (e.g. `http://proxy.corp.com:8080`).
///
/// The in-container supervisor chains policy-approved TLS tunnels
/// through this proxy with HTTP CONNECT instead of dialing upstream
/// destinations directly. Only `http://` proxy URLs are supported.
/// Per-sandbox environment values take precedence.
pub https_proxy: Option<String>,
/// Corporate forward proxy URL injected as `HTTP_PROXY`/`http_proxy`,
/// used for plain HTTP requests. See `https_proxy`.
pub http_proxy: Option<String>,
/// Comma-separated `NO_PROXY` list injected alongside the proxy URLs
/// (e.g. `*.svc.cluster.local,10.0.0.0/8`). Destinations matching an
/// entry are dialed directly instead of through the corporate proxy.
pub no_proxy: Option<String>,
}

pub const DEFAULT_HEALTH_CHECK_INTERVAL_SECS: u64 = 10;
Expand Down Expand Up @@ -189,6 +204,35 @@ impl PodmanComputeConfig {
Ok(())
}

/// Validate optional corporate proxy configuration.
///
/// The in-container supervisor only supports `http://` forward proxies,
/// so reject other schemes at config time instead of silently ignoring
/// them inside every sandbox.
pub fn validate_proxy_config(&self) -> Result<(), crate::client::PodmanApiError> {
for (field, value) in [
("https_proxy", &self.https_proxy),
("http_proxy", &self.http_proxy),
] {
let Some(url) = value else { continue };
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(crate::client::PodmanApiError::InvalidInput(format!(
"{field} must not be empty when set"
)));
}
if let Some((scheme, _)) = trimmed.split_once("://")
&& !scheme.eq_ignore_ascii_case("http")
{
return Err(crate::client::PodmanApiError::InvalidInput(format!(
"{field} uses unsupported scheme '{scheme}': only http:// forward \
proxies are supported by the sandbox supervisor"
)));
}
}
Ok(())
}

/// Validate optional host gateway override.
pub fn validate_host_gateway_ip(&self) -> Result<(), crate::client::PodmanApiError> {
let trimmed = self.host_gateway_ip.trim();
Expand Down Expand Up @@ -262,6 +306,9 @@ impl Default for PodmanComputeConfig {
sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT,
enable_bind_mounts: false,
health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS,
https_proxy: None,
http_proxy: None,
no_proxy: None,
}
}
}
Expand All @@ -288,6 +335,10 @@ impl std::fmt::Debug for PodmanComputeConfig {
"health_check_interval_secs",
&self.health_check_interval_secs,
)
// Proxy URLs may embed credentials in userinfo; log presence only.
.field("https_proxy", &self.https_proxy.is_some())
.field("http_proxy", &self.http_proxy.is_some())
.field("no_proxy", &self.no_proxy)
.finish()
}
}
Expand Down Expand Up @@ -397,6 +448,49 @@ mod tests {
});
}

// ── Proxy config validation ───────────────────────────────────────

#[test]
fn validate_proxy_config_accepts_unset_and_http() {
assert!(
PodmanComputeConfig::default()
.validate_proxy_config()
.is_ok()
);
let cfg = PodmanComputeConfig {
https_proxy: Some("http://proxy.corp.com:8080".to_string()),
http_proxy: Some("proxy.corp.com:3128".to_string()),
no_proxy: Some("*.svc.cluster.local".to_string()),
..PodmanComputeConfig::default()
};
assert!(cfg.validate_proxy_config().is_ok());
}

#[test]
fn validate_proxy_config_rejects_non_http_schemes() {
for url in ["https://proxy:443", "socks5://proxy:1080"] {
let cfg = PodmanComputeConfig {
https_proxy: Some(url.to_string()),
..PodmanComputeConfig::default()
};
let err = cfg.validate_proxy_config().unwrap_err();
assert!(
err.to_string().contains("unsupported scheme"),
"{url}: {err}"
);
}
}

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

// ── TLS config validation ─────────────────────────────────────────

#[test]
Expand Down
98 changes: 98 additions & 0 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,25 @@ fn build_env(
env.insert(openshell_core::sandbox_env::USER_ENVIRONMENT.into(), json);
}

// 1.5. Operator-configured corporate egress proxy. The in-container
// supervisor reads these from its own environment and chains approved
// upstream dials through the corporate proxy; the workload child's proxy
// variables are rewritten separately to point at the local policy proxy.
// Inserted as defaults so per-sandbox user env wins. Both cases are set
// because proxy-aware tooling disagrees on which one it reads.
for (name, value) in [
("HTTPS_PROXY", &config.https_proxy),
("https_proxy", &config.https_proxy),
("HTTP_PROXY", &config.http_proxy),
("http_proxy", &config.http_proxy),
("NO_PROXY", &config.no_proxy),
("no_proxy", &config.no_proxy),
] {
if let Some(value) = value {
env.entry(name.into()).or_insert_with(|| value.clone());
}
}

// 2. Required driver vars (highest priority -- always overwrite).
env.insert(
openshell_core::sandbox_env::SANDBOX.into(),
Expand Down Expand Up @@ -1675,6 +1694,85 @@ mod tests {
);
}

#[test]
fn container_spec_injects_operator_proxy_env() {
let sandbox = test_sandbox("test-id", "test-name");
let mut config = test_config();
config.https_proxy = Some("http://proxy.corp.com:8080".to_string());
config.http_proxy = Some("http://proxy.corp.com:3128".to_string());
config.no_proxy = Some("*.svc.cluster.local,10.0.0.0/8".to_string());

let spec = build_container_spec(&sandbox, &config);
let env_map = spec["env"].as_object().expect("env should be an object");

for key in ["HTTPS_PROXY", "https_proxy"] {
assert_eq!(
env_map.get(key).and_then(|v| v.as_str()),
Some("http://proxy.corp.com:8080"),
"{key} should carry the operator proxy URL"
);
}
for key in ["HTTP_PROXY", "http_proxy"] {
assert_eq!(
env_map.get(key).and_then(|v| v.as_str()),
Some("http://proxy.corp.com:3128"),
"{key} should carry the operator proxy URL"
);
}
for key in ["NO_PROXY", "no_proxy"] {
assert_eq!(
env_map.get(key).and_then(|v| v.as_str()),
Some("*.svc.cluster.local,10.0.0.0/8"),
"{key} should carry the operator NO_PROXY list"
);
}
}

#[test]
fn container_spec_omits_proxy_env_when_unconfigured() {
let sandbox = test_sandbox("test-id", "test-name");
let spec = build_container_spec(&sandbox, &test_config());
let env_map = spec["env"].as_object().expect("env should be an object");

for key in ["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"] {
assert!(
!env_map.contains_key(key),
"{key} should be absent without operator proxy config"
);
}
}

#[test]
fn container_spec_user_env_overrides_operator_proxy() {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};

let mut sandbox = test_sandbox("test-id", "test-name");
sandbox.spec = Some(DriverSandboxSpec {
environment: std::collections::HashMap::from([(
"HTTPS_PROXY".to_string(),
"http://sandbox-specific:9999".to_string(),
)]),
template: Some(DriverSandboxTemplate::default()),
..Default::default()
});
let mut config = test_config();
config.https_proxy = Some("http://proxy.corp.com:8080".to_string());

let spec = build_container_spec(&sandbox, &config);
let env_map = spec["env"].as_object().expect("env should be an object");

assert_eq!(
env_map.get("HTTPS_PROXY").and_then(|v| v.as_str()),
Some("http://sandbox-specific:9999"),
"per-sandbox HTTPS_PROXY should win over operator config"
);
assert_eq!(
env_map.get("https_proxy").and_then(|v| v.as_str()),
Some("http://proxy.corp.com:8080"),
"unset lowercase variant still falls back to operator config"
);
}

#[test]
fn container_spec_required_labels_cannot_be_overridden() {
use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate};
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-podman/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ impl PodmanComputeDriver {
config.validate_tls_config()?;
config.validate_runtime_limits()?;
config.validate_host_gateway_ip()?;
config.validate_proxy_config()?;

let client = PodmanClient::new(config.socket_path.clone());

Expand Down
16 changes: 16 additions & 0 deletions crates/openshell-driver-podman/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ struct Args {
/// Host path to the client private key for sandbox mTLS.
#[arg(long, env = "OPENSHELL_PODMAN_TLS_KEY")]
podman_tls_key: Option<PathBuf>,

/// Corporate forward proxy URL injected into sandbox containers as
/// `HTTPS_PROXY` for the supervisor's upstream dials (http:// only).
#[arg(long, env = "OPENSHELL_SANDBOX_HTTPS_PROXY")]
sandbox_https_proxy: Option<String>,

/// Corporate forward proxy URL injected as `HTTP_PROXY` (http:// only).
#[arg(long, env = "OPENSHELL_SANDBOX_HTTP_PROXY")]
sandbox_http_proxy: Option<String>,

/// Comma-separated `NO_PROXY` list injected alongside the proxy URLs.
#[arg(long, env = "OPENSHELL_SANDBOX_NO_PROXY")]
sandbox_no_proxy: Option<String>,
}

#[tokio::main]
Expand Down Expand Up @@ -137,6 +150,9 @@ async fn main() -> Result<()> {
guest_tls_cert: args.podman_tls_cert,
guest_tls_key: args.podman_tls_key,
sandbox_pids_limit: args.sandbox_pids_limit,
https_proxy: args.sandbox_https_proxy,
http_proxy: args.sandbox_http_proxy,
no_proxy: args.sandbox_no_proxy,
..PodmanComputeConfig::default()
})
.await
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ pub mod run;
pub mod sigv4;
mod spiffe_endpoint;
mod token_grant;
pub mod upstream_proxy;
Loading
Loading