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
4 changes: 4 additions & 0 deletions Cargo.lock

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

15 changes: 15 additions & 0 deletions crates/openshell-core/src/external_driver_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use tokio_stream::Stream;

/// Prepare and bind a private Unix socket owned by the current effective UID.
pub fn bind_private(path: &Path) -> Result<UnixListener, String> {
crate::unix_socket_path::check(path).map_err(|err| err.to_string())?;
let parent = path
.parent()
.ok_or_else(|| format!("driver socket path '{}' has no parent", path.display()))?;
Expand Down Expand Up @@ -173,6 +174,20 @@ mod tests {
assert!(err.contains("must be a directory, not a symlink"));
}

#[test]
fn bind_private_rejects_overlong_path_before_creating_parent() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
let parent = temp_dir
.path()
.join("a".repeat(crate::unix_socket_path::MAX_LEN));

let err = bind_private(&parent.join("compute.sock"))
.expect_err("overlong socket path must be rejected");

assert!(err.contains("-byte limit"), "{err}");
assert!(!parent.exists());
}

#[test]
fn bind_private_rejects_existing_non_socket() {
let temp_dir = tempfile::tempdir().expect("create temporary directory");
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub mod time;
pub mod token_grant_failure;
pub mod transport_errors;
pub mod upload;
pub use openshell_extension_core::unix_socket_path;

pub use config::{
AppArmorProfile, Config, ExtensionBindingPolicy, GatewayAuthConfig,
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-kubernetes-secrets/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ fn driver_config(args: &Args) -> toml::Table {
}

fn prepare_socket(socket_path: &Path) -> Result<()> {
openshell_core::unix_socket_path::check(socket_path).into_diagnostic()?;
let parent = socket_path.parent().ok_or_else(|| {
miette!(
"credential driver socket path '{}' has no parent directory",
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-driver-vault/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ fn insert_path(config: &mut toml::Table, key: &str, value: Option<&PathBuf>) {
}

fn prepare_socket(socket_path: &Path) -> Result<()> {
openshell_core::unix_socket_path::check(socket_path).into_diagnostic()?;
let parent = socket_path.parent().ok_or_else(|| {
miette!(
"credential driver socket path '{}' has no parent directory",
Expand Down
8 changes: 4 additions & 4 deletions crates/openshell-driver-vm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ By default `mise run gateway:vm`:
`~/.config/openshell/gateways/vm-dev/metadata.json`. It does not modify the
workspace `.env`.
- Persists the gateway SQLite DB under `.cache/gateway-vm/gateway.db`.
- Places the VM driver state (per-sandbox `overlay.ext4`, image cache, and `run/compute-driver.sock`) under `/tmp/openshell-vm-driver-$USER-vm-dev/` so the AF_UNIX socket path stays under macOS `SUN_LEN`.
- Places the VM driver state (per-sandbox `overlay.ext4` and image cache) under `/tmp/openshell-vm-driver-$USER-vm-dev/`.
- Writes `.cache/gateway-vm/gateway.toml` with `[openshell.drivers.vm].driver_dir = "$PWD/target/debug"` so the freshly built `openshell-driver-vm` is used instead of an older installed copy from `~/.local/libexec/openshell`, `/usr/libexec/openshell`, or `/usr/local/libexec`.
- Enables OTLP trace export, OCSF log export, and the agent trace relay to `http://127.0.0.1:4317` only when a local collector is listening there. Otherwise, it omits the OTLP configuration to avoid repeated export failures.

Expand Down Expand Up @@ -166,7 +166,7 @@ Select the VM driver with `--compute-driver vm`, `OPENSHELL_COMPUTE_DRIVER=vm`,
| Configuration key | Default | Purpose |
|---|---|---|
| `grpc_endpoint` | empty | Required. URL the native host supervisor uses to reach the gateway. Host loopback such as `http://127.0.0.1:<port>` is valid. Legacy guest aliases are normalized to host loopback. This endpoint is never sent into the VM. |
| `state_dir` | `target/openshell-vm-driver` | Per-sandbox overlay disks, console logs, image cache, and private `run/compute-driver.sock` UDS. Relative paths are resolved to absolute paths at driver startup. |
| `state_dir` | `target/openshell-vm-driver` | Per-sandbox overlay disks, console logs, and image cache. Relative paths are resolved to absolute paths at driver startup. |
| `driver_dir` | unset | Override the directory searched for `openshell-driver-vm`. |
| `default_image` | OpenShell base image | Sandbox image used when a create request omits one. |
| `bootstrap_image` | unset | VM runtime image used as the immutable bootstrap root disk. Falls back only to the operator-configured `default_image`; the driver refuses to start when both are empty. |
Expand Down Expand Up @@ -283,8 +283,8 @@ RUST_LOG=openshell_server=debug,openshell_driver_vm=debug \
mise run gateway:vm
```

The VM guest's serial console is appended to `<state-dir>/<sandbox-id>/console.log`. Sandbox IDs must match `[A-Za-z0-9._-]{1,128}` before the driver uses them in host paths. The gateway-owned compute-driver socket lives at `<state-dir>/run/compute-driver.sock`; OpenShell creates `run/` with owner-only permissions and removes same-owner stale sockets. On clean shutdown, the gateway sends the managed driver `SIGTERM`, waits up to five seconds for it to flush telemetry and exit, then force-kills it if necessary and removes the socket. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit `--allow-same-uid-peer` development flag. TCP mode is disabled by default because it is unauthenticated; use `--allow-unauthenticated-tcp --bind-address 127.0.0.1:50061` only for local development.
The VM serial console is appended to `<state-dir>/sandboxes/<sandbox-id>/rootfs-console.log`. Host-supervisor stdout and stderr are written beside it as `supervisor.log` and `supervisor.err.log`. Sandbox IDs must match `[A-Za-z0-9._-]{1,128}` before the driver uses them in host paths. The gateway-owned compute-driver socket lives at `<state-dir>/run/compute-driver.sock`; OpenShell creates `run/` with owner-only permissions, removes same-owner stale sockets, and the gateway removes the socket on clean shutdown via `ManagedDriverProcess::drop`. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit `--allow-same-uid-peer` development flag. TCP mode is disabled by default because it is unauthenticated; use `--allow-unauthenticated-tcp --bind-address 127.0.0.1:50061` only for local development.
The VM guest's serial console is appended to `<state-dir>/<sandbox-id>/console.log`. Sandbox IDs must match `[A-Za-z0-9._-]{1,128}` before the driver uses them in host paths. The gateway-owned compute-driver socket lives in a private, randomly named `/tmp/os-gw-<euid>-<random>/` directory, independent of `state_dir`. On clean shutdown, the gateway sends the managed driver `SIGTERM`, waits up to five seconds for it to flush telemetry and exit, then force-kills it if necessary and removes the socket directory. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit `--allow-same-uid-peer` development flag. TCP mode is disabled by default because it is unauthenticated; use `--allow-unauthenticated-tcp --bind-address 127.0.0.1:50061` only for local development.
The VM serial console is appended to `<state-dir>/sandboxes/<sandbox-id>/rootfs-console.log`. Host-supervisor stdout and stderr are written beside it as `supervisor.log` and `supervisor.err.log`. Sandbox IDs must match `[A-Za-z0-9._-]{1,128}` before the driver uses them in host paths. The gateway-owned compute-driver socket lives in a private, randomly named `/tmp/os-gw-<euid>-<random>/` directory, which `ManagedDriverProcess::drop` removes. UDS clients must match the driver UID and provide the expected gateway process PID by default. Standalone same-UID UDS mode requires the explicit `--allow-same-uid-peer` development flag. TCP mode is disabled by default because it is unauthenticated; use `--allow-unauthenticated-tcp --bind-address 127.0.0.1:50061` only for local development.

## Network isolation

Expand Down
78 changes: 48 additions & 30 deletions crates/openshell-driver-vm/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,7 +1472,9 @@ impl VmDriver {
.env(openshell_core::sandbox_env::SANDBOX, &sandbox.name)
.env(
openshell_core::sandbox_env::SSH_SOCKET_PATH,
sandbox_socket_dir(&self.socket_root, &sandbox.id).join("ssh.sock"),
sandbox_socket_dir(&self.socket_root, &sandbox.id)
.map_err(|err| Status::internal(format!("sandbox socket dir: {err}")))?
.join("ssh.sock"),
)
.env(
openshell_core::sandbox_env::PROXY_TLS_DIR,
Expand Down Expand Up @@ -2082,10 +2084,10 @@ impl VmDriver {
}

let console_output = state_dir.join("rootfs-console.log");
create_sandbox_socket_dir(&self.socket_root_fd, &self.socket_root, &sandbox.id)
.map_err(|err| Status::internal(format!("create socket dir failed: {err}")))?;
let control_socket =
sandbox_socket_dir(&self.socket_root, &sandbox.id).join(VM_CONTROL_SOCKET);
create_sandbox_socket_dir(&self.socket_root_fd, &self.socket_root, &sandbox.id)
.map_err(|err| Status::internal(format!("create socket dir failed: {err}")))?
.join(VM_CONTROL_SOCKET);
let session_id = launch_authentication.supervisor.session_id;
let channel_tls = generate_sandbox_tls_material(session_id)
.map_err(|error| Status::internal(error.to_string()))?;
Expand Down Expand Up @@ -6811,11 +6813,20 @@ async fn restrict_owner_only_dir(_path: &Path) -> Result<(), std::io::Error> {
}

const SOCKET_ROOT_ALLOC_RETRIES: usize = 16;
/// macOS `sun_path` is 104 bytes including the NUL terminator.
const MAX_SUN_PATH_LEN: usize = 103;

fn sandbox_socket_dir(socket_root: &Path, sandbox_id: &str) -> PathBuf {
socket_root.join(sandbox_id)
/// Name of a sandbox's socket directory under the socket root. Sandbox IDs can
/// be 128 bytes, so a fixed-length digest keeps socket paths short.
fn sandbox_socket_leaf(sandbox_id: &str) -> Result<String, std::io::Error> {
let digest = openshell_crypto::sha256(sandbox_id.as_bytes()).map_err(std::io::Error::other)?;
let mut leaf = String::with_capacity(32);
for byte in &digest[..16] {
write!(&mut leaf, "{byte:02x}").expect("writing to String cannot fail");
}
Ok(leaf)
}

fn sandbox_socket_dir(socket_root: &Path, sandbox_id: &str) -> Result<PathBuf, std::io::Error> {
Ok(socket_root.join(sandbox_socket_leaf(sandbox_id)?))
}

fn allocate_socket_root() -> Result<(PathBuf, OwnedFd), std::io::Error> {
Expand Down Expand Up @@ -6864,34 +6875,34 @@ fn create_sandbox_socket_dir(
socket_root: &Path,
sandbox_id: &str,
) -> Result<PathBuf, std::io::Error> {
let longest = socket_root.join(sandbox_id).join(VM_CONTROL_SOCKET);
if longest.as_os_str().len() > MAX_SUN_PATH_LEN {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"socket path {} exceeds sun_path limit ({MAX_SUN_PATH_LEN} bytes)",
longest.display()
),
));
}
match rustix::fs::mkdirat(root_fd, sandbox_id, rustix::fs::Mode::from_raw_mode(0o700)) {
let leaf = sandbox_socket_leaf(sandbox_id)?;
let dir = socket_root.join(&leaf);
openshell_core::unix_socket_path::check(&dir.join(VM_CONTROL_SOCKET))?;
match rustix::fs::mkdirat(
root_fd,
leaf.as_str(),
rustix::fs::Mode::from_raw_mode(0o700),
) {
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
Err(e) => return Err(e.into()),
}
Ok(socket_root.join(sandbox_id))
Ok(dir)
}

fn remove_sandbox_socket_dir(root_fd: &OwnedFd, sandbox_id: &str) {
let Ok(leaf) = sandbox_socket_leaf(sandbox_id) else {
return;
};
if let Ok(leaf_fd) = rustix::fs::openat(
root_fd,
sandbox_id,
leaf.as_str(),
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::NOFOLLOW,
rustix::fs::Mode::empty(),
) {
let _ = rustix::fs::unlinkat(&leaf_fd, VM_CONTROL_SOCKET, rustix::fs::AtFlags::empty());
let _ = rustix::fs::unlinkat(&leaf_fd, "ssh.sock", rustix::fs::AtFlags::empty());
}
let _ = rustix::fs::unlinkat(root_fd, sandbox_id, rustix::fs::AtFlags::REMOVEDIR);
let _ = rustix::fs::unlinkat(root_fd, leaf.as_str(), rustix::fs::AtFlags::REMOVEDIR);
}

#[allow(clippy::result_large_err)]
Expand Down Expand Up @@ -8341,7 +8352,8 @@ mod tests {
use tonic::Code;

fn test_socket_root() -> (PathBuf, Arc<OwnedFd>) {
let dir = std::env::temp_dir().join(format!(
// Production socket roots live under /tmp; macOS TMPDIR is too long.
let dir = Path::new("/tmp").join(format!(
"os-test-{:016x}",
u64::from_ne_bytes(openshell_crypto::random_bytes::<8>().expect("test entropy"))
));
Expand Down Expand Up @@ -10572,20 +10584,26 @@ mod tests {
}

#[test]
fn create_sandbox_socket_dir_rejects_over_long_ids() {
fn create_sandbox_socket_dir_accepts_longest_sandbox_id() {
let (root, fd) = test_socket_root();
let err = create_sandbox_socket_dir(&fd, &root, &"x".repeat(128)).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(!root.join("x".repeat(128)).exists());
let sandbox_id = "x".repeat(128);
let dir = create_sandbox_socket_dir(&fd, &root, &sandbox_id).unwrap();
assert!(dir.is_dir());
assert_eq!(dir, sandbox_socket_dir(&root, &sandbox_id).unwrap());
std::os::unix::net::UnixListener::bind(dir.join(VM_CONTROL_SOCKET)).unwrap();
remove_sandbox_socket_dir(&fd, &sandbox_id);
assert!(!dir.exists());
let _ = std::fs::remove_dir_all(root);
}

#[test]
fn sandbox_socket_dir_fits_macos_sun_path() {
let sandbox_id = "3eb2ad45-bead-4c2e-bd10-1a4a7f3a2721";
let sandbox_id = "x".repeat(128);
let worst_root = "/tmp/os-4294967295-00000000000000000000000000000000";
let control = sandbox_socket_dir(Path::new(worst_root), sandbox_id).join(VM_CONTROL_SOCKET);
let ssh = sandbox_socket_dir(Path::new(worst_root), sandbox_id).join("ssh.sock");
let dir = sandbox_socket_dir(Path::new(worst_root), &sandbox_id).unwrap();
let control = dir.join(VM_CONTROL_SOCKET);
let ssh = dir.join("ssh.sock");
assert_ne!(dir, sandbox_socket_dir(Path::new(worst_root), "y").unwrap());
assert!(
control.as_os_str().len() < 104,
"control socket path exceeds macOS sun_path: {}",
Expand Down
15 changes: 15 additions & 0 deletions crates/openshell-driver-vm/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ fn compute_driver_listen_mode(args: &Args) -> std::result::Result<ComputeDriverL
.to_string(),
);
}
openshell_core::unix_socket_path::check(&socket_path).map_err(|err| err.to_string())?;
return Ok(ComputeDriverListenMode::Unix {
socket_path,
expected_peer_pid: args.expected_peer_pid,
Expand Down Expand Up @@ -1063,6 +1064,20 @@ mod tests {
);
}

#[test]
fn listen_mode_rejects_overlong_uds_path() {
let socket_path = format!("/tmp/{}/compute-driver.sock", "a".repeat(100));
let args = Args::parse_from([
"openshell-driver-vm",
"--bind-socket",
&socket_path,
"--expected-peer-pid",
"42",
]);
let err = compute_driver_listen_mode(&args).expect_err("overlong socket path");
assert!(err.contains(&socket_path), "{err}");
}

#[test]
fn listen_mode_accepts_explicit_same_uid_uds_dev_mode() {
let args = Args::parse_from([
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-extension-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ http = { workspace = true }
rcgen = { workspace = true }
rustls = { workspace = true }
serde_json = { workspace = true }
tempfile = "3"
tokio-stream = { workspace = true, features = ["net"] }
tonic = { workspace = true, features = ["server", "tls-native-roots"] }

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-extension-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod identity;
mod jwt;
mod store;
mod transport;
pub mod unix_socket_path;

pub use auth::{BearerTokenInterceptor, BearerTokenSlot, TokenSlotError};
pub use identity::{ExtensionAudience, ExtensionIdentity, ExtensionKind, IdentityError};
Expand Down
18 changes: 13 additions & 5 deletions crates/openshell-extension-core/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub enum TransportError {
CustomCaRequiresHttps,
#[error("unix extension endpoint must contain an absolute socket path")]
InvalidUnixPath,
#[error("unix extension endpoint: {0}")]
UnixPathTooLong(std::io::Error),
#[error("unix extension endpoints are not supported on this platform")]
UnixUnsupported,
#[error("invalid extension endpoint: {0}")]
Expand Down Expand Up @@ -149,10 +151,11 @@ fn validate_config(config: &ExtensionChannelConfig) -> Result<(), TransportError
if !matches!(&config.server_trust, ExtensionServerTrust::PlatformRoots) && !is_https {
return Err(TransportError::CustomCaRequiresHttps);
}
if let Some(path) = config.endpoint.strip_prefix("unix://")
&& (path.is_empty() || !PathBuf::from(path).is_absolute())
{
return Err(TransportError::InvalidUnixPath);
if let Some(path) = config.endpoint.strip_prefix("unix://") {
if path.is_empty() || !PathBuf::from(path).is_absolute() {
return Err(TransportError::InvalidUnixPath);
}
crate::unix_socket_path::check(path.as_ref()).map_err(TransportError::UnixPathTooLong)?;
}
Ok(())
}
Expand Down Expand Up @@ -271,7 +274,7 @@ mod tests {
}

#[test]
fn rejects_unsupported_schemes_and_relative_unix_paths() {
fn rejects_unsupported_schemes_and_invalid_unix_paths() {
assert!(matches!(
validate_config(&ExtensionChannelConfig::new("tcp://middleware:50051")),
Err(TransportError::UnsupportedScheme)
Expand All @@ -280,6 +283,11 @@ mod tests {
validate_config(&ExtensionChannelConfig::new("unix://relative.sock")),
Err(TransportError::InvalidUnixPath)
));
let long = format!("unix:///{}", "a".repeat(crate::unix_socket_path::MAX_LEN));
assert!(matches!(
validate_config(&ExtensionChannelConfig::new(long)),
Err(TransportError::UnixPathTooLong(_))
));
}

#[test]
Expand Down
Loading
Loading