Skip to content

Commit b75abad

Browse files
committed
fix(sandbox): add managed loopback proxy
1 parent 528fb29 commit b75abad

8 files changed

Lines changed: 421 additions & 54 deletions

File tree

crates/openshell-core/src/sandbox_env.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ pub const LOG_LEVEL: &str = "OPENSHELL_LOG_LEVEL";
2626
/// Shell command to run inside the sandbox.
2727
pub const SANDBOX_COMMAND: &str = "OPENSHELL_SANDBOX_COMMAND";
2828

29+
/// Sandbox-local loopback HTTP proxy URL managed by the supervisor.
30+
///
31+
/// This is distinct from HTTP_PROXY/HTTPS_PROXY, which continue to point at the
32+
/// gateway-side proxy address for ordinary proxy-aware clients.
33+
pub const LOOPBACK_PROXY_URL: &str = "OPENSHELL_LOOPBACK_PROXY_URL";
34+
2935
/// Path to the CA certificate for mTLS communication with the gateway.
3036
pub const TLS_CA: &str = "OPENSHELL_TLS_CA";
3137

crates/openshell-sandbox/src/child_env.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ pub fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] {
2121
]
2222
}
2323

24+
pub fn loopback_proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 1] {
25+
[(
26+
openshell_core::sandbox_env::LOOPBACK_PROXY_URL,
27+
proxy_url.to_owned(),
28+
)]
29+
}
30+
2431
pub fn tls_env_vars(
2532
ca_cert_path: &Path,
2633
combined_bundle_path: &Path,
@@ -65,6 +72,27 @@ mod tests {
6572
assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1"));
6673
}
6774

75+
#[test]
76+
fn apply_loopback_proxy_env_exposes_managed_url_without_changing_proxy_vars() {
77+
let mut cmd = Command::new("/usr/bin/env");
78+
cmd.stdin(Stdio::null())
79+
.stdout(Stdio::piped())
80+
.stderr(Stdio::null());
81+
82+
for (key, value) in proxy_env_vars("http://10.200.0.1:3128") {
83+
cmd.env(key, value);
84+
}
85+
for (key, value) in loopback_proxy_env_vars("http://127.0.0.1:3128") {
86+
cmd.env(key, value);
87+
}
88+
89+
let output = cmd.output().expect("spawn env");
90+
let stdout = String::from_utf8(output.stdout).expect("utf8");
91+
92+
assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128"));
93+
assert!(stdout.contains("OPENSHELL_LOOPBACK_PROXY_URL=http://127.0.0.1:3128"));
94+
}
95+
6896
#[test]
6997
fn apply_tls_env_sets_node_and_bundle_paths() {
7098
let mut cmd = Command::new("/usr/bin/env");

crates/openshell-sandbox/src/lib.rs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ use crate::l7::tls::{
174174
};
175175
use crate::opa::OpaEngine;
176176
use crate::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy};
177+
#[cfg(target_os = "linux")]
178+
use crate::proxy::LoopbackProxyHandle;
177179
use crate::proxy::ProxyHandle;
178180
#[cfg(target_os = "linux")]
179181
use crate::sandbox::linux::netns::NetworkNamespace;
@@ -571,8 +573,10 @@ pub async fn run_sandbox(
571573
// the entrypoint process's /proc/net/tcp for identity binding.
572574
let entrypoint_pid = Arc::new(AtomicU32::new(0));
573575

574-
let (_proxy, denial_rx, bypass_denial_tx) = if matches!(policy.network.mode, NetworkMode::Proxy)
575-
{
576+
let (_proxy, loopback_proxy, denial_rx, bypass_denial_tx) = if matches!(
577+
policy.network.mode,
578+
NetworkMode::Proxy
579+
) {
576580
let proxy_policy = policy.network.proxy.as_ref().ok_or_else(|| {
577581
miette::miette!("Network mode is set to proxy but no proxy configuration was provided")
578582
})?;
@@ -617,21 +621,66 @@ pub async fn run_sandbox(
617621
let proxy_handle = ProxyHandle::start_with_bind_addr(
618622
proxy_policy,
619623
bind_addr,
620-
engine,
621-
cache,
624+
engine.clone(),
625+
cache.clone(),
622626
entrypoint_pid.clone(),
623-
tls_state,
624-
inference_ctx,
627+
tls_state.clone(),
628+
inference_ctx.clone(),
625629
Some(provider_credentials.clone()),
626630
Some(policy_local_ctx.clone()),
627-
denial_tx,
631+
denial_tx.clone(),
628632
)
629633
.await?;
630-
(Some(proxy_handle), denial_rx, bypass_denial_tx)
634+
635+
#[cfg(target_os = "linux")]
636+
let loopback_proxy_handle = if let (Some(ns), Some(_upstream_addr)) =
637+
(netns.as_ref(), bind_addr)
638+
{
639+
let Some(netns_fd) = ns.ns_fd() else {
640+
return Err(miette::miette!(
641+
"Managed loopback proxy requires a sandbox network namespace file descriptor"
642+
));
643+
};
644+
let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port());
645+
let listen_addr: SocketAddr = ([127, 0, 0, 1], port).into();
646+
Some(LoopbackProxyHandle::start_in_netns(
647+
netns_fd,
648+
listen_addr,
649+
engine,
650+
cache,
651+
entrypoint_pid.clone(),
652+
tls_state,
653+
inference_ctx,
654+
Some(provider_credentials.clone()),
655+
Some(policy_local_ctx.clone()),
656+
denial_tx,
657+
)?)
658+
} else {
659+
None
660+
};
661+
662+
#[cfg(not(target_os = "linux"))]
663+
let loopback_proxy_handle: Option<()> = None;
664+
665+
(
666+
Some(proxy_handle),
667+
loopback_proxy_handle,
668+
denial_rx,
669+
bypass_denial_tx,
670+
)
631671
} else {
632-
(None, None, None)
672+
(None, None, None, None)
633673
};
634674

675+
#[cfg(target_os = "linux")]
676+
let loopback_proxy_url = loopback_proxy.as_ref().map(LoopbackProxyHandle::proxy_url);
677+
678+
#[cfg(not(target_os = "linux"))]
679+
let _ = &loopback_proxy;
680+
681+
#[cfg(not(target_os = "linux"))]
682+
let loopback_proxy_url: Option<String> = None;
683+
635684
// Spawn bypass detection monitor (Linux only, proxy mode only).
636685
// Reads /dev/kmsg for nftables log entries and emits structured
637686
// tracing events for direct connection attempts that bypass the proxy.
@@ -758,6 +807,7 @@ pub async fn run_sandbox(
758807
let policy_clone = policy.clone();
759808
let workdir_clone = workdir.clone();
760809
let proxy_url = ssh_proxy_url;
810+
let loopback_proxy_url = loopback_proxy_url.clone();
761811
let netns_fd = ssh_netns_fd;
762812
let ca_paths = ca_file_paths.clone();
763813
let provider_credentials_clone = provider_credentials.clone();
@@ -772,6 +822,7 @@ pub async fn run_sandbox(
772822
workdir_clone,
773823
netns_fd,
774824
proxy_url,
825+
loopback_proxy_url,
775826
ca_paths,
776827
provider_credentials_clone,
777828
)
@@ -838,6 +889,7 @@ pub async fn run_sandbox(
838889
interactive,
839890
&policy,
840891
netns.as_ref(),
892+
loopback_proxy_url.as_deref(),
841893
ca_file_paths.as_ref(),
842894
&provider_env,
843895
)?;
@@ -849,6 +901,7 @@ pub async fn run_sandbox(
849901
workdir.as_deref(),
850902
interactive,
851903
&policy,
904+
loopback_proxy_url.as_deref(),
852905
ca_file_paths.as_ref(),
853906
&provider_env,
854907
)?;

crates/openshell-sandbox/src/process.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ impl ProcessHandle {
9494
interactive: bool,
9595
policy: &SandboxPolicy,
9696
netns: Option<&NetworkNamespace>,
97+
loopback_proxy_url: Option<&str>,
9798
ca_paths: Option<&(PathBuf, PathBuf)>,
9899
provider_env: &HashMap<String, String>,
99100
) -> Result<Self> {
@@ -104,6 +105,7 @@ impl ProcessHandle {
104105
interactive,
105106
policy,
106107
netns.and_then(NetworkNamespace::ns_fd),
108+
loopback_proxy_url,
107109
ca_paths,
108110
provider_env,
109111
)
@@ -121,6 +123,7 @@ impl ProcessHandle {
121123
workdir: Option<&str>,
122124
interactive: bool,
123125
policy: &SandboxPolicy,
126+
loopback_proxy_url: Option<&str>,
124127
ca_paths: Option<&(PathBuf, PathBuf)>,
125128
provider_env: &HashMap<String, String>,
126129
) -> Result<Self> {
@@ -130,6 +133,7 @@ impl ProcessHandle {
130133
workdir,
131134
interactive,
132135
policy,
136+
loopback_proxy_url,
133137
ca_paths,
134138
provider_env,
135139
)
@@ -144,6 +148,7 @@ impl ProcessHandle {
144148
interactive: bool,
145149
policy: &SandboxPolicy,
146150
netns_fd: Option<RawFd>,
151+
loopback_proxy_url: Option<&str>,
147152
ca_paths: Option<&(PathBuf, PathBuf)>,
148153
provider_env: &HashMap<String, String>,
149154
) -> Result<Self> {
@@ -185,6 +190,12 @@ impl ProcessHandle {
185190
}
186191
}
187192

193+
if let Some(url) = loopback_proxy_url {
194+
for (key, value) in child_env::loopback_proxy_env_vars(url) {
195+
cmd.env(key, value);
196+
}
197+
}
198+
188199
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
189200
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
190201
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
@@ -270,6 +281,7 @@ impl ProcessHandle {
270281
workdir: Option<&str>,
271282
interactive: bool,
272283
policy: &SandboxPolicy,
284+
loopback_proxy_url: Option<&str>,
273285
ca_paths: Option<&(PathBuf, PathBuf)>,
274286
provider_env: &HashMap<String, String>,
275287
) -> Result<Self> {
@@ -301,6 +313,12 @@ impl ProcessHandle {
301313
}
302314
}
303315

316+
if let Some(url) = loopback_proxy_url {
317+
for (key, value) in child_env::loopback_proxy_env_vars(url) {
318+
cmd.env(key, value);
319+
}
320+
}
321+
304322
// Set TLS trust store env vars so sandbox processes trust the ephemeral CA
305323
if let Some((ca_cert_path, combined_bundle_path)) = ca_paths {
306324
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {

0 commit comments

Comments
 (0)