Skip to content

Commit f12f3ef

Browse files
authored
fix(macos): restore Homebrew sandbox callbacks (#2739)
* fix(macos): restore Docker gateway callbacks Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(gateway): reuse reachable primary callback listener Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 1074566 commit f12f3ef

16 files changed

Lines changed: 255 additions & 144 deletions

File tree

.agents/skills/debug-openshell-cluster/SKILL.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ Common findings:
178178
- A workdir rejected as a special filesystem or OpenShell control-path collision cannot be made valid with permissions. Move the image workdir away from kernel-backed mounts and the concrete supervisor, TLS, token, runtime, and socket paths named in the error.
179179
- Docker driver cannot initialize because it cannot find `openshell-sandbox`: verify `OPENSHELL_DOCKER_SUPERVISOR_BIN`, the sibling binary next to `openshell-gateway`, or the configured supervisor image contains `/openshell-sandbox`.
180180
- Sandbox never registers: check gateway logs and supervisor callback endpoint.
181+
- On macOS, repeated `Policy fetch failed after 5 attempts` messages with a
182+
Homebrew gateway bound to `[::1]:17670` indicate that the Docker
183+
`host-gateway` IPv4 route has no matching callback listener. Current releases
184+
leave `bind_address` unset in the Homebrew config, use the built-in
185+
`127.0.0.1:17670` primary listener, and reuse it for authenticated sandbox
186+
callbacks. On an older release, set `bind_address = "127.0.0.1:17670"` or
187+
upgrade.
181188
- Supervisor image exits before printing `openshell-sandbox --version`: the image should be the scratch supervisor image from `deploy/docker/Dockerfile.supervisor` and must contain a static executable at `/openshell-sandbox`.
182189
- `mise run e2e:docker:gpu` fails with `docker info --format json did not report any discovered NVIDIA CDI GPU devices`: Docker may report `CDISpecDirs` while still having no generated NVIDIA CDI specs. Verify `.DiscoveredDevices` contains entries such as `nvidia.com/gpu=all`, verify `/etc/cdi` or `/var/run/cdi` contains a generated NVIDIA spec, and check that `nvidia-cdi-refresh.service` and `nvidia-cdi-refresh.path` from NVIDIA Container Toolkit are enabled and healthy. The service is a one-shot unit, so `inactive (dead)` can be normal after a successful run; use `systemctl status` and `journalctl` to distinguish success from a skipped or failed refresh. NVIDIA recommends enabling the path and service units, and restarting `nvidia-cdi-refresh.service` to regenerate missing or stale CDI specs. If specs are generated but Docker still reports no discovered devices, restart Docker or reload the daemon and re-check `docker info`.
183190

@@ -207,14 +214,9 @@ Common findings:
207214
error: inspect `podman info --debug`, the configured Podman network, and the
208215
host's IPv4 default route. Rootless pasta uses the private source address
209216
selected by that route; rootful Podman uses the bridge gateway address.
210-
- Callback discovery reports that the requested address equals the primary
211-
listener: configure a distinct primary address. For Podman Machine, bind the
212-
primary listener to IPv6 loopback, for example
213-
`bind_address = "[::1]:17670"`, and register the CLI endpoint as
214-
`https://localhost:17670`. The generated certificate includes `localhost`,
215-
while a raw `https://[::1]:17670` endpoint can fail TLS setup with
216-
`invalid dns name`. This leaves `127.0.0.1:17670` available for the
217-
callback-only listener.
217+
- Current gateways reuse the primary listener when it covers Podman's callback
218+
address. If the primary does not cover that address, inspect the gateway
219+
startup logs for the additional callback-only listener and its provenance.
218220
- Rootless slirp4netns, another named helper, or missing helper metadata
219221
requires an explicitly remote `grpc_endpoint`. An explicit `host_gateway_ip`
220222
cannot bypass slirp4netns host-loopback isolation. Do not work around
@@ -544,7 +546,7 @@ openshell logs <sandbox-name>
544546
| `openshell status` fails | Gateway endpoint unreachable or auth mismatch | `openshell gateway info`, gateway logs |
545547
| Gateway starts but sandbox create fails | Compute driver cannot reach runtime | Docker/Podman/Kubernetes/VM driver logs |
546548
| Gateway exits while resolving compute-driver listener requirements | Callback alias topology is unsupported, the Podman network cannot be inspected, or the selected address is not private/authorized | Gateway startup error, `podman info --debug`, Podman network inspection, host IPv4 default route |
547-
| Admin, health, reflection, or HTTP request is denied on a Docker/Podman callback address | Negotiated callback listeners intentionally expose only sandbox-callable gRPC methods | Retry through the gateway's primary endpoint; inspect the listener-purpose startup log if the address was unexpected |
549+
| Admin, health, reflection, or HTTP request is denied on an additional Docker/Podman callback-only listener | Additional callback listeners intentionally expose only sandbox-callable gRPC methods | Retry through the gateway's primary endpoint; inspect the listener-purpose startup log if the address was unexpected |
548550
| Docker or Podman sandbox never registers | Wrong callback endpoint or supervisor startup failure | Gateway logs and sandbox container logs |
549551
| Docker GPU e2e fails before GPU sandbox comparison | NVIDIA CDI specs are missing or Docker has not discovered them | `docker info --format '{{json .DiscoveredDevices}}'`, `/etc/cdi`, `/var/run/cdi`, `nvidia-cdi-refresh.service` |
550552
| Kubernetes gateway pod pending | PVC unbound, taint, selector, or insufficient resources | `kubectl -n openshell describe pod <pod>` |

architecture/gateway.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,17 @@ health, metrics, or tunnel routes. The plaintext service router also rejects
3737
browser requests whose Fetch Metadata, Origin, or Referer headers indicate a
3838
cross-origin or sibling-subdomain request.
3939

40-
Docker and Podman may negotiate additional listeners that make the gateway
41-
reachable from their local sandbox network topology. Those listeners accept
40+
Docker and Podman report the local address through which their sandboxes can
41+
reach the gateway. When the primary listener covers that address, the gateway
42+
reuses it; sandbox JWT authentication and its RPC allowlist remain the callback
43+
authorization boundary. When the primary listener does not cover the address,
44+
the gateway adds a callback-only listener. Additional callback listeners accept
4245
only gRPC methods classified as sandbox-callable by the gateway's generated
4346
authorization metadata. They reject user and administrator APIs, health,
4447
reflection, non-callback inference APIs, and HTTP routes before normal request
4548
authentication. The operator-configured primary listener retains the full
4649
multiplexed API surface.
4750

48-
The gateway rejects a callback requirement that resolves to the exact primary
49-
listener address because one socket cannot preserve two authorization scopes.
50-
A wildcard primary listener may cover a callback address because the accepted
51-
connection's concrete local address still selects the callback-only scope.
52-
5351
The `rpc_auth` classification is also the source of truth for negotiated
5452
listener exposure: marking an RPC as `sandbox` or `dual` makes it callable on
5553
these listeners. Review such changes as both authorization and network-surface
@@ -699,8 +697,10 @@ system entry instead of pretending to delete package-manager owned state.
699697
- Compute runtimes own the mechanics of starting workloads and injecting
700698
callback configuration.
701699
- Docker-backed local gateways use Docker's `host-gateway` callback alias on
702-
macOS and Docker Desktop-style runtimes. Native Linux Docker may expose an
703-
additional bridge-gateway listener because the host can bind that bridge IP.
700+
macOS and Docker Desktop-style runtimes. They request IPv4 loopback callback
701+
reachability and add a listener only when the primary does not cover it.
702+
Native Linux Docker may expose an additional bridge-gateway listener because
703+
the host can bind that bridge IP.
704704
- Podman-backed macOS gateways use gvproxy's host-loopback IP for sandbox host
705705
aliases by default so stale Podman machine images do not need Podman's
706706
`host-gateway` resolver. Linux Podman keeps the resolver unless

crates/openshell-cli/src/tls.rs

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use tokio::sync::Mutex;
2222
use tonic::service::interceptor::InterceptedService;
2323
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
2424
use tracing::debug;
25+
use url::{Host, Url};
2526

2627
/// Concrete gRPC client type used by all commands.
2728
pub type GrpcClient = OpenShellClient<InterceptedService<Channel, EdgeAuthInterceptor>>;
@@ -223,12 +224,33 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result<rustls::ClientCon
223224
.into_diagnostic()
224225
}
225226

226-
pub fn build_tonic_tls_config(materials: &TlsMaterials) -> ClientTlsConfig {
227+
pub fn build_tonic_tls_config(server: &str, materials: &TlsMaterials) -> Result<ClientTlsConfig> {
227228
let ca_cert = Certificate::from_pem(materials.ca.clone());
228229
let identity = Identity::from_pem(materials.cert.clone(), materials.key.clone());
229-
ClientTlsConfig::new()
230-
.ca_certificate(ca_cert)
231-
.identity(identity)
230+
tls_config_with_server_name(
231+
server,
232+
ClientTlsConfig::new()
233+
.ca_certificate(ca_cert)
234+
.identity(identity),
235+
)
236+
}
237+
238+
fn tls_config_with_server_name(server: &str, config: ClientTlsConfig) -> Result<ClientTlsConfig> {
239+
Ok(config.domain_name(tls_server_name(server)?))
240+
}
241+
242+
fn tls_server_name(server: &str) -> Result<String> {
243+
let endpoint = Url::parse(server)
244+
.into_diagnostic()
245+
.wrap_err_with(|| format!("invalid gateway endpoint '{server}'"))?;
246+
let host = endpoint
247+
.host()
248+
.ok_or_else(|| miette::miette!("gateway endpoint '{server}' has no host"))?;
249+
Ok(match host {
250+
Host::Domain(domain) => domain.to_string(),
251+
Host::Ipv4(address) => address.to_string(),
252+
Host::Ipv6(address) => address.to_string(),
253+
})
232254
}
233255

234256
#[derive(Debug)]
@@ -402,30 +424,28 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
402424
// Bearer auth over HTTPS: use mTLS certs for the transport layer when
403425
// available (server may still require client certs), and layer the
404426
// Bearer token on top via the interceptor.
405-
require_tls_materials(server, tls).map_or_else(
406-
|_| {
407-
let resolved = tls.with_default_paths(server);
408-
resolved
409-
.ca
410-
.as_ref()
411-
.and_then(|ca_path| std::fs::read(ca_path).ok())
412-
.map_or_else(
413-
|| ClientTlsConfig::new().with_enabled_roots(),
414-
|ca_pem| {
415-
ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem))
416-
},
417-
)
418-
},
419-
|materials| build_tonic_tls_config(&materials),
420-
)
427+
if let Ok(materials) = require_tls_materials(server, tls) {
428+
build_tonic_tls_config(server, &materials)?
429+
} else {
430+
let resolved = tls.with_default_paths(server);
431+
let config = resolved
432+
.ca
433+
.as_ref()
434+
.and_then(|ca_path| std::fs::read(ca_path).ok())
435+
.map_or_else(
436+
|| ClientTlsConfig::new().with_enabled_roots(),
437+
|ca_pem| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem)),
438+
);
439+
tls_config_with_server_name(server, config)?
440+
}
421441
} else if tls.edge_token.is_some() {
422442
// Edge bearer mode — routed through tunnel above; if we reach here
423443
// the server is not HTTPS so connect plaintext.
424444
return endpoint.connect().await.into_diagnostic();
425445
} else {
426446
// Standard mTLS: private CA + client cert.
427447
let materials = require_tls_materials(server, tls)?;
428-
build_tonic_tls_config(&materials)
448+
build_tonic_tls_config(server, &materials)?
429449
};
430450
endpoint = endpoint.tls_config(tls_config).into_diagnostic()?;
431451
endpoint.connect().await.into_diagnostic()
@@ -451,3 +471,25 @@ pub async fn grpc_inference_client(server: &str, tls: &TlsOptions) -> Result<Grp
451471
let interceptor = interceptor_from_tls(tls)?;
452472
Ok(InferenceClient::with_interceptor(channel, interceptor))
453473
}
474+
475+
#[cfg(test)]
476+
mod tests {
477+
use super::tls_server_name;
478+
479+
#[test]
480+
fn tls_server_name_normalizes_bracketed_ipv6_endpoint() {
481+
assert_eq!(
482+
tls_server_name("https://[::1]:17670").unwrap(),
483+
"::1",
484+
"rustls accepts an unbracketed IPv6 address as an IP server name"
485+
);
486+
}
487+
488+
#[test]
489+
fn tls_server_name_preserves_dns_endpoint() {
490+
assert_eq!(
491+
tls_server_name("https://localhost:17670").unwrap(),
492+
"localhost"
493+
);
494+
}
495+
}

crates/openshell-driver-docker/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ Docker containers join an OpenShell-managed bridge network. The driver injects
6767
`host.openshell.internal` and `host.docker.internal` so supervisors have stable
6868
names for reaching the gateway host. On Docker Desktop, Colima, Rancher
6969
Desktop, OrbStack, and macOS-hosted gateways, those names use Docker's
70-
`host-gateway` alias. On native Linux Docker, the gateway also binds the bridge
71-
gateway IP so containers can call back to the host process.
70+
`host-gateway` alias. The driver requests a separate IPv4 loopback callback
71+
listener when the primary listener does not already cover it. On native Linux
72+
Docker, the gateway also binds the bridge gateway IP so containers can call
73+
back to the host process.
7274

7375
## Container Contract
7476

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ use openshell_core::proto_struct::{
5555
use openshell_core::{Config, Error, Result as CoreResult};
5656
use std::collections::{HashMap, HashSet};
5757
use std::io::Read;
58-
use std::net::{IpAddr, SocketAddr};
58+
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5959
use std::path::{Path, PathBuf};
6060
use std::pin::Pin;
6161
use std::sync::Arc;
@@ -177,6 +177,7 @@ struct DockerDriverRuntimeConfig {
177177
grpc_endpoint: String,
178178
network_name: String,
179179
gateway_route: DockerGatewayRoute,
180+
gateway_callback_bind_address: Option<SocketAddr>,
180181
ssh_socket_path: String,
181182
stop_timeout_secs: u32,
182183
log_level: String,
@@ -428,6 +429,8 @@ impl DockerComputeDriver {
428429
let host_gateway_ip = parse_optional_host_gateway_ip(&docker_config.host_gateway_ip)?;
429430
let gateway_route =
430431
docker_gateway_route(&info, bridge_gateway_ip, gateway_port, host_gateway_ip);
432+
let gateway_callback_bind_address =
433+
docker_gateway_callback_bind_address(&gateway_route, config.bind_address);
431434
let mut docker_config = docker_config.clone();
432435
if docker_config.grpc_endpoint.trim().is_empty() {
433436
let scheme = if docker_guest_tls_configured(&docker_config) {
@@ -456,6 +459,7 @@ impl DockerComputeDriver {
456459
grpc_endpoint,
457460
network_name,
458461
gateway_route,
462+
gateway_callback_bind_address,
459463
ssh_socket_path: docker_config.ssh_socket_path.clone(),
460464
stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS,
461465
log_level: config.log_level.clone(),
@@ -1577,15 +1581,19 @@ impl ComputeDriver for DockerComputeDriver {
15771581
&self,
15781582
_request: Request<GetGatewayListenerRequirementsRequest>,
15791583
) -> Result<Response<GetGatewayListenerRequirementsResponse>, Status> {
1580-
let requirements = match self.config.gateway_route {
1581-
DockerGatewayRoute::Bridge { bind_address, .. } => {
1582-
vec![GatewayListenerRequirement {
1583-
reason: "docker managed bridge gateway".to_string(),
1584-
selector: Some(Selector::ExactBindAddress(bind_address.to_string())),
1585-
}]
1586-
}
1587-
DockerGatewayRoute::HostGateway => Vec::new(),
1588-
};
1584+
let requirements =
1585+
self.config
1586+
.gateway_callback_bind_address
1587+
.map_or_else(Vec::new, |bind_address| {
1588+
vec![GatewayListenerRequirement {
1589+
reason: match self.config.gateway_route {
1590+
DockerGatewayRoute::Bridge { .. } => "docker managed bridge gateway",
1591+
DockerGatewayRoute::HostGateway => "docker host-gateway IPv4 loopback",
1592+
}
1593+
.to_string(),
1594+
selector: Some(Selector::ExactBindAddress(bind_address.to_string())),
1595+
}]
1596+
});
15891597
Ok(Response::new(GetGatewayListenerRequirementsResponse {
15901598
requirements,
15911599
}))
@@ -2793,6 +2801,22 @@ fn docker_gateway_route_for_host(
27932801
}
27942802
}
27952803

2804+
fn docker_gateway_callback_bind_address(
2805+
route: &DockerGatewayRoute,
2806+
primary_bind_address: SocketAddr,
2807+
) -> Option<SocketAddr> {
2808+
match route {
2809+
DockerGatewayRoute::Bridge { bind_address, .. } => Some(*bind_address),
2810+
DockerGatewayRoute::HostGateway => match primary_bind_address.ip() {
2811+
IpAddr::V4(ip) if ip.is_unspecified() || ip == Ipv4Addr::LOCALHOST => None,
2812+
_ => Some(SocketAddr::new(
2813+
IpAddr::V4(Ipv4Addr::LOCALHOST),
2814+
primary_bind_address.port(),
2815+
)),
2816+
},
2817+
}
2818+
}
2819+
27962820
fn host_runtime_requires_host_gateway_alias() -> bool {
27972821
cfg!(target_os = "macos")
27982822
}

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ fn runtime_config() -> DockerDriverRuntimeConfig {
102102
),
103103
host_alias_ip: IpAddr::V4(Ipv4Addr::new(172, 18, 0, 1)),
104104
},
105+
gateway_callback_bind_address: Some(SocketAddr::new(
106+
IpAddr::V4(Ipv4Addr::new(172, 18, 0, 1)),
107+
DEFAULT_SERVER_PORT,
108+
)),
105109
ssh_socket_path: "/run/openshell/ssh.sock".to_string(),
106110
stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS,
107111
log_level: "info".to_string(),
@@ -186,6 +190,7 @@ async fn gateway_listener_requirements_report_managed_bridge_address() {
186190
async fn gateway_listener_requirements_are_empty_for_host_gateway_route() {
187191
let mut config = runtime_config();
188192
config.gateway_route = DockerGatewayRoute::HostGateway;
193+
config.gateway_callback_bind_address = None;
189194
let driver = test_driver_with_config(config);
190195

191196
let response = driver
@@ -197,6 +202,26 @@ async fn gateway_listener_requirements_are_empty_for_host_gateway_route() {
197202
assert!(response.requirements.is_empty());
198203
}
199204

205+
#[tokio::test]
206+
async fn host_gateway_route_reports_ipv4_loopback_callback_listener() {
207+
let mut config = runtime_config();
208+
config.gateway_route = DockerGatewayRoute::HostGateway;
209+
config.gateway_callback_bind_address = Some("127.0.0.1:17670".parse().unwrap());
210+
let driver = test_driver_with_config(config);
211+
212+
let response = driver
213+
.get_gateway_listener_requirements(Request::new(GetGatewayListenerRequirementsRequest {}))
214+
.await
215+
.unwrap()
216+
.into_inner();
217+
218+
assert_eq!(response.requirements.len(), 1);
219+
assert_eq!(
220+
response.requirements[0].selector,
221+
Some(Selector::ExactBindAddress("127.0.0.1:17670".to_string()))
222+
);
223+
}
224+
200225
#[test]
201226
fn container_visible_endpoint_rewrites_loopback_hosts() {
202227
assert_eq!(
@@ -298,6 +323,31 @@ fn docker_gateway_route_uses_host_gateway_for_docker_desktop() {
298323
);
299324
}
300325

326+
#[test]
327+
fn host_gateway_route_requests_ipv4_loopback_for_ipv6_primary() {
328+
assert_eq!(
329+
docker_gateway_callback_bind_address(
330+
&DockerGatewayRoute::HostGateway,
331+
"[::1]:17670".parse().unwrap(),
332+
),
333+
Some("127.0.0.1:17670".parse().unwrap())
334+
);
335+
}
336+
337+
#[test]
338+
fn host_gateway_route_reuses_ipv4_primary_when_it_covers_loopback() {
339+
for primary in ["127.0.0.1:17670", "0.0.0.0:17670"] {
340+
assert_eq!(
341+
docker_gateway_callback_bind_address(
342+
&DockerGatewayRoute::HostGateway,
343+
primary.parse().unwrap(),
344+
),
345+
None,
346+
"{primary} already covers the IPv4 loopback callback"
347+
);
348+
}
349+
}
350+
301351
#[test]
302352
fn docker_gateway_route_uses_host_gateway_for_colima() {
303353
let info = SystemInfo {

0 commit comments

Comments
 (0)