Skip to content

Commit fcefdd5

Browse files
authored
feat(driver-docker): use host networking for sandboxes (#1080)
1 parent 9751872 commit fcefdd5

8 files changed

Lines changed: 264 additions & 66 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

architecture/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ This opens an interactive SSH session into the sandbox, with all provider creden
299299
| [Docs Site Architecture](docs-site.md) | Documentation source layout, navigation structure, local validation and preview workflow, and publish pipeline. |
300300
| [Policy Language](security-policy.md) | The YAML/Rego policy system that governs sandbox behavior. |
301301
| [Inference Routing](inference-routing.md) | Transparent interception and sandbox-local routing of AI inference API calls to configured backends. |
302+
| [Docker Driver](docker-driver.md) | Docker compute driver implementation, host networking, loopback gateway connectivity. |
302303
| [System Architecture](system-architecture.md) | Top-level system architecture diagram with all deployable components and communication flows. |
303304
| [Gateway Settings Channel](gateway-settings.md) | Runtime settings channel: two-tier key-value configuration, global policy override, settings registry, CLI/TUI commands. |
304305
| [TUI](tui.md) | Terminal user interface for sandbox interaction. |

architecture/docker-driver.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Docker Driver
2+
3+
The Docker compute driver manages sandbox containers through the local Docker
4+
daemon using the `bollard` client. It targets local developer environments
5+
where running a full Kubernetes cluster is unnecessary but Docker is already
6+
available.
7+
8+
The gateway remains a host process. Each sandbox container bind-mounts a Linux
9+
`openshell-sandbox` supervisor binary and uses Docker host networking so the
10+
supervisor can connect to a gateway that is listening on host loopback without
11+
requiring an additional bridge-reachable listener on Linux.
12+
13+
## Source Map
14+
15+
| Path | Purpose |
16+
|---|---|
17+
| `crates/openshell-driver-docker/src/lib.rs` | Docker compute driver implementation |
18+
| `crates/openshell-driver-docker/src/tests.rs` | Unit tests for container spec, env, TLS paths, GPU, resource limits, and cache helpers |
19+
| `crates/openshell-server/src/cli.rs` | Gateway CLI flags for Docker driver configuration |
20+
| `crates/openshell-server/src/lib.rs` | In-process Docker compute runtime wiring |
21+
22+
## Runtime Model
23+
24+
```mermaid
25+
flowchart LR
26+
CLI["OpenShell CLI<br/>host"] -->|gRPC/HTTP<br/>127.0.0.1:8080| GW["Gateway<br/>host process"]
27+
GW -->|Docker API| DA["Docker daemon"]
28+
DA --> C["Sandbox container<br/>network_mode=host"]
29+
C --> SV["openshell-sandbox<br/>supervisor"]
30+
SV -->|ConnectSupervisor<br/>OPENSHELL_ENDPOINT| GW
31+
SV --> NS["Nested sandbox netns<br/>workload + policy proxy"]
32+
```
33+
34+
The Docker container itself uses `network_mode = "host"`. This is intentional
35+
for now: it makes a gateway bound to `127.0.0.1` reachable from the supervisor
36+
as `127.0.0.1`, matching the host process' endpoint without a bridge listener,
37+
NAT rule, or userland proxy.
38+
39+
The container also gets a Docker-managed `/etc/hosts` entry for
40+
`host.openshell.internal` that resolves to `127.0.0.1`. This gives callers a
41+
stable OpenShell-owned hostname for host services without requiring changes to
42+
the host machine's hosts file.
43+
44+
The supervisor still creates a nested network namespace for the actual workload
45+
and routes workload traffic through its policy proxy. Agent network requests are
46+
enforced by the supervisor in that nested namespace.
47+
48+
## Container Spec
49+
50+
`build_container_create_body()` constructs the Docker container:
51+
52+
| Field | Value | Reason |
53+
|---|---|---|
54+
| `image` | Sandbox template image | User-selected runtime image |
55+
| `user` | `"0"` | Supervisor needs root inside the container for namespace and mount setup |
56+
| `entrypoint` | `/opt/openshell/bin/openshell-sandbox` | Bind-mounted supervisor binary |
57+
| `cmd` | Empty vector | Prevents image CMD args from being appended to the supervisor entrypoint |
58+
| `network_mode` | `"host"` | Lets supervisor connect to host loopback gateway endpoints |
59+
| `extra_hosts` | `host.openshell.internal:127.0.0.1` | Stable container-local alias for host loopback services |
60+
| `cap_add` | `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `SYSLOG` | Required for supervisor isolation setup and process inspection |
61+
| `security_opt` | `apparmor=unconfined` | Docker's default AppArmor profile blocks mount operations required by network namespace setup |
62+
| `restart_policy` | `unless-stopped` | Resume managed sandboxes after Docker or gateway restarts |
63+
| `device_requests` | CDI all-GPU request when `spec.gpu` is true | Enables Docker CDI GPU sandboxes when daemon support is detected |
64+
65+
## Gateway Callback
66+
67+
The Docker driver injects `OPENSHELL_ENDPOINT` into each sandbox container from
68+
`Config::grpc_endpoint` without rewriting it. This is the key difference from a
69+
bridge-network design.
70+
71+
Examples:
72+
73+
```shell
74+
OPENSHELL_GRPC_ENDPOINT=http://127.0.0.1:8080
75+
```
76+
77+
and:
78+
79+
```shell
80+
OPENSHELL_GRPC_ENDPOINT=https://127.0.0.1:8080
81+
```
82+
83+
are passed into the supervisor as-is. Because the container shares the host
84+
network namespace, `127.0.0.1` resolves to the host loopback interface and the
85+
gateway is reachable when it binds loopback.
86+
87+
The endpoint can also use the stable alias:
88+
89+
```shell
90+
OPENSHELL_GRPC_ENDPOINT=http://host.openshell.internal:8080
91+
```
92+
93+
In host network mode this name resolves to `127.0.0.1` inside the container.
94+
95+
For TLS endpoints, the gateway certificate must include the exact endpoint host
96+
as a subject alternative name. For `https://127.0.0.1:8080`, the certificate
97+
needs an IP SAN for `127.0.0.1`. For `https://localhost:8080`, it needs a DNS
98+
SAN for `localhost`. For `https://host.openshell.internal:8080`, it needs a DNS
99+
SAN for `host.openshell.internal`. Docker sandboxes also require client TLS
100+
material:
101+
102+
| Env / flag | Purpose |
103+
|---|---|
104+
| `OPENSHELL_DOCKER_TLS_CA` / `--docker-tls-ca` | CA certificate mounted at `/etc/openshell/tls/client/ca.crt` |
105+
| `OPENSHELL_DOCKER_TLS_CERT` / `--docker-tls-cert` | Client certificate mounted at `/etc/openshell/tls/client/tls.crt` |
106+
| `OPENSHELL_DOCKER_TLS_KEY` / `--docker-tls-key` | Client private key mounted at `/etc/openshell/tls/client/tls.key` |
107+
108+
When `OPENSHELL_GRPC_ENDPOINT` uses `http://`, these TLS mounts are not
109+
required and providing them is rejected. When it uses `https://`, all three are
110+
required.
111+
112+
## Environment
113+
114+
`build_environment()` merges template environment, spec environment, and
115+
driver-controlled keys. Driver-controlled keys win:
116+
117+
| Variable | Value |
118+
|---|---|
119+
| `OPENSHELL_ENDPOINT` | Exact configured gateway endpoint |
120+
| `OPENSHELL_SANDBOX_ID` | Sandbox id |
121+
| `OPENSHELL_SANDBOX` | Sandbox name |
122+
| `OPENSHELL_SSH_SOCKET_PATH` | Unix socket path used by the supervisor's embedded SSH daemon |
123+
| `OPENSHELL_SANDBOX_COMMAND` | `sleep infinity` |
124+
| `OPENSHELL_TLS_CA` | Mounted CA path when HTTPS is enabled |
125+
| `OPENSHELL_TLS_CERT` | Mounted client cert path when HTTPS is enabled |
126+
| `OPENSHELL_TLS_KEY` | Mounted client key path when HTTPS is enabled |
127+
128+
The Docker driver does not inject `OPENSHELL_SSH_HANDSHAKE_SECRET`; the
129+
supervisor-to-gateway path relies on mTLS for the Docker callback.

crates/openshell-core/src/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ pub struct Config {
140140
#[serde(default)]
141141
pub metrics_bind_address: Option<SocketAddr>,
142142

143+
/// Additional bind addresses that serve the same multiplexed gRPC/HTTP
144+
/// surface as `bind_address`.
145+
///
146+
/// Compute drivers may register extra listeners during startup so that
147+
/// sandbox workloads can call back into the gateway over an interface
148+
/// that the operator-supplied `bind_address` does not expose.
149+
#[serde(default)]
150+
pub extra_bind_addresses: Vec<SocketAddr>,
151+
143152
/// Log level (trace, debug, info, warn, error).
144153
#[serde(default = "default_log_level")]
145154
pub log_level: String,
@@ -327,6 +336,7 @@ impl Config {
327336
bind_address: default_bind_address(),
328337
health_bind_address: None,
329338
metrics_bind_address: None,
339+
extra_bind_addresses: Vec::new(),
330340
log_level: default_log_level(),
331341
tls,
332342
oidc: None,
@@ -368,6 +378,19 @@ impl Config {
368378
self
369379
}
370380

381+
/// Append an extra listener address to the multiplex service.
382+
///
383+
/// Duplicate entries (matching `bind_address` or any existing entry) are
384+
/// silently dropped so callers can naively push driver-derived addresses
385+
/// without checking for collisions.
386+
#[must_use]
387+
pub fn with_extra_bind_address(mut self, addr: SocketAddr) -> Self {
388+
if addr != self.bind_address && !self.extra_bind_addresses.contains(&addr) {
389+
self.extra_bind_addresses.push(addr);
390+
}
391+
self
392+
}
393+
371394
/// Create a new configuration with the given log level.
372395
#[must_use]
373396
pub fn with_log_level(mut self, level: impl Into<String>) -> Self {

crates/openshell-driver-docker/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ futures = { workspace = true }
1919
tokio-stream = { workspace = true }
2020
tracing = { workspace = true }
2121
bytes = { workspace = true }
22-
url = { workspace = true }
2322
bollard = { version = "0.20" }
2423
tar = "0.4"
2524
tempfile = "3"

crates/openshell-driver-docker/src/lib.rs

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ use tokio::sync::{broadcast, mpsc};
3838
use tokio_stream::wrappers::ReceiverStream;
3939
use tonic::{Request, Response, Status};
4040
use tracing::{info, warn};
41-
use url::{Host, Url};
4241

4342
const WATCH_BUFFER: usize = 128;
4443
const WATCH_POLL_INTERVAL: Duration = Duration::from_secs(2);
@@ -55,8 +54,7 @@ const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt";
5554
const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt";
5655
const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key";
5756
const SANDBOX_COMMAND: &str = "sleep infinity";
58-
const HOST_OPENSHELL_INTERNAL: &str = "host.openshell.internal";
59-
const HOST_DOCKER_INTERNAL: &str = "host.docker.internal";
57+
const HOST_OPENSHELL_INTERNAL_HOSTS_ENTRY: &str = "host.openshell.internal:127.0.0.1";
6058

6159
/// Default image holding the Linux `openshell-sandbox` binary. The gateway
6260
/// pulls this image and extracts the binary to a host-side cache when no
@@ -852,7 +850,7 @@ fn build_environment(sandbox: &DriverSandbox, config: &DockerDriverRuntimeConfig
852850

853851
environment.insert(
854852
"OPENSHELL_ENDPOINT".to_string(),
855-
container_visible_openshell_endpoint(&config.grpc_endpoint),
853+
config.grpc_endpoint.clone(),
856854
);
857855
environment.insert("OPENSHELL_SANDBOX_ID".to_string(), sandbox.id.clone());
858856
environment.insert("OPENSHELL_SANDBOX".to_string(), sandbox.name.clone());
@@ -950,17 +948,27 @@ fn build_container_create_body(
950948
"SYS_PTRACE".to_string(),
951949
"SYSLOG".to_string(),
952950
]),
953-
// AppArmor's default Docker profile blocks mount(2) with MS_SHARED
954-
// even when SYS_ADMIN is granted, which prevents ip-netns from
955-
// creating network namespaces for proxy-mode isolation. The sandbox
956-
// enforces its own isolation via seccomp, Landlock, and network
957-
// namespaces, so the host AppArmor profile adds no meaningful
958-
// defence here.
951+
// The sandbox supervisor needs to bind-mount `/run/netns`,
952+
// mark it shared, and create per-process network namespaces.
953+
// Docker's default AppArmor profile (`docker-default`) denies
954+
// these mount operations even with CAP_SYS_ADMIN, so we opt
955+
// out of AppArmor confinement for sandbox containers. The
956+
// sandbox enforces its own security boundary via Landlock,
957+
// seccomp, OPA policy evaluation, and the dedicated network
958+
// namespace it sets up for the agent — AppArmor at the
959+
// container layer is redundant relative to those controls
960+
// and conflicts with them in this case.
959961
security_opt: Some(vec!["apparmor=unconfined".to_string()]),
960-
extra_hosts: Some(vec![
961-
format!("{HOST_DOCKER_INTERNAL}:host-gateway"),
962-
format!("{HOST_OPENSHELL_INTERNAL}:host-gateway"),
963-
]),
962+
// Run in the host network namespace so a gateway bound to
963+
// 127.0.0.1 is reachable from the supervisor as 127.0.0.1.
964+
// The supervisor still creates a nested network namespace for
965+
// the sandboxed workload and forces workload traffic through
966+
// its policy proxy.
967+
network_mode: Some("host".to_string()),
968+
// Keep a stable host alias available inside the container without
969+
// requiring users to edit the host's /etc/hosts. In host network
970+
// mode this resolves back to the host loopback gateway.
971+
extra_hosts: Some(vec![HOST_OPENSHELL_INTERNAL_HOSTS_ENTRY.to_string()]),
964972
..Default::default()
965973
}),
966974
..Default::default()
@@ -991,25 +999,6 @@ fn sandbox_log_level(sandbox: &DriverSandbox, default_level: &str) -> String {
991999
.to_string()
9921000
}
9931001

994-
fn container_visible_openshell_endpoint(endpoint: &str) -> String {
995-
let Ok(mut url) = Url::parse(endpoint) else {
996-
return endpoint.to_string();
997-
};
998-
999-
let should_rewrite = match url.host() {
1000-
Some(Host::Ipv4(ip)) => ip.is_loopback() || ip.is_unspecified(),
1001-
Some(Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unspecified(),
1002-
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1003-
None => false,
1004-
};
1005-
1006-
if should_rewrite && url.set_host(Some(HOST_OPENSHELL_INTERNAL)).is_ok() {
1007-
return url.to_string();
1008-
}
1009-
1010-
endpoint.to_string()
1011-
}
1012-
10131002
fn docker_resource_limits(
10141003
template: &DriverSandboxTemplate,
10151004
) -> Result<DockerResourceLimits, Status> {

crates/openshell-driver-docker/src/tests.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,16 @@ fn runtime_config() -> DockerDriverRuntimeConfig {
5757
}
5858

5959
#[test]
60-
fn container_visible_endpoint_rewrites_loopback_hosts() {
61-
assert_eq!(
62-
container_visible_openshell_endpoint("https://localhost:8443"),
63-
"https://host.openshell.internal:8443/"
64-
);
65-
assert_eq!(
66-
container_visible_openshell_endpoint("http://127.0.0.1:8080"),
67-
"http://host.openshell.internal:8080/"
68-
);
69-
assert_eq!(
70-
container_visible_openshell_endpoint("https://gateway.internal:8443"),
71-
"https://gateway.internal:8443"
72-
);
60+
fn build_environment_preserves_loopback_endpoint_for_host_network() {
61+
let mut config = runtime_config();
62+
config.grpc_endpoint = "http://127.0.0.1:8080".to_string();
63+
64+
let env = build_environment(&test_sandbox(), &config);
65+
assert!(env.contains(&"OPENSHELL_ENDPOINT=http://127.0.0.1:8080".to_string()));
66+
67+
config.grpc_endpoint = "https://localhost:8443".to_string();
68+
let env = build_environment(&test_sandbox(), &config);
69+
assert!(env.contains(&"OPENSHELL_ENDPOINT=https://localhost:8443".to_string()));
7370
}
7471

7572
#[test]
@@ -231,6 +228,23 @@ fn require_sandbox_identifier_rejects_when_id_and_name_are_empty() {
231228
require_sandbox_identifier("sbx-1", "demo").expect("id and name is accepted");
232229
}
233230

231+
#[test]
232+
fn build_container_create_body_uses_host_network() {
233+
let create_body = build_container_create_body(&test_sandbox(), &runtime_config()).unwrap();
234+
let host_config = create_body.host_config.expect("host_config is populated");
235+
236+
assert_eq!(
237+
host_config.network_mode,
238+
Some("host".to_string()),
239+
"sandbox must use host networking so 127.0.0.1 reaches the host gateway"
240+
);
241+
assert_eq!(
242+
host_config.extra_hosts,
243+
Some(vec!["host.openshell.internal:127.0.0.1".to_string()]),
244+
"sandbox should expose a stable host alias without host /etc/hosts edits"
245+
);
246+
}
247+
234248
#[test]
235249
fn build_container_create_body_uses_runtime_namespace_label() {
236250
// Regression test: the namespace label must come from the driver's

0 commit comments

Comments
 (0)