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
1 change: 1 addition & 0 deletions Cargo.lock

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

24 changes: 23 additions & 1 deletion architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ reason strings.
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API, OCI image volumes, and CDI GPU devices when available. |
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Gateway spawns `openshell-driver-vm` as a subprocess over a private, state-local Unix socket. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. |
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Gateway spawns `openshell-driver-vm` as a subprocess over a private, state-local Unix socket. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay; the gateway explicitly calls the driver's resume RPC on startup so it can supply a fresh sandbox token before the VM is relaunched. |

Per-sandbox CPU and memory values currently enter the driver layer through
template resource limits. Docker and Podman apply them as runtime limits.
Expand Down Expand Up @@ -64,6 +64,28 @@ Driver-controlled environment variables must override sandbox image or template
values for sandbox ID, sandbox name, gateway endpoint, relay socket path, TLS
paths, and command metadata.

## Sandbox Tokens

When gateway-minted sandbox JWTs are enabled, each runtime declares its token
contract with `OPENSHELL_SANDBOX_AUTH_MODE`:

- Docker and Podman use `gateway-managed-file`. The gateway writes host token
files that are mounted read-only into the container, and the supervisor
re-reads `OPENSHELL_SANDBOX_TOKEN_FILE` on outbound gateway calls.
- VM uses `gateway-managed-supervisor-push`. The gateway supplies a fresh token
through the driver's resume/write RPCs and sends live token updates over
`ConnectSupervisor` so the guest can rewrite
`/opt/openshell/auth/sandbox.jwt`.
- Kubernetes uses `kubernetes-service-account-exchange`. The supervisor reads
the projected ServiceAccount token from `OPENSHELL_K8S_SA_TOKEN_FILE` and
exchanges it for a gateway JWT with `IssueSandboxToken`.

During startup, local-driver resume hooks receive a freshly minted token before
starting or re-adopting each persisted sandbox. The gateway also runs a refresh
sweep after startup resume and then rotates local-runtime tokens before expiry.
This lets a local sandbox recover after the gateway, container, or VM was
stopped long enough for the previous token to expire.

## Images

The gateway image and Helm chart are built from this repository. Sandbox images
Expand Down
4 changes: 1 addition & 3 deletions crates/openshell-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
url = { workspace = true }
ipnet = "2"
tempfile = "3"

[features]
## Include test-only settings (dummy_bool, dummy_int) in the registry.
Expand All @@ -31,8 +32,5 @@ dev-settings = []
tonic-build = { workspace = true }
protobuf-src = { workspace = true }

[dev-dependencies]
tempfile = "3"

[lints]
workspace = true
48 changes: 48 additions & 0 deletions crates/openshell-core/src/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,38 @@ pub fn ensure_parent_dir_restricted(path: &Path) -> Result<()> {
Ok(())
}

/// Atomically write a sensitive file with owner-only read/write permissions.
///
/// The parent directory is created with [`create_dir_restricted`]. The content
/// is written to a sibling temporary file, synced, chmodded to `0o600` on Unix,
/// and then renamed into place.
pub fn write_file_owner_only_atomic(path: &Path, contents: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| miette::miette!("path has no parent: {}", path.display()))?;
create_dir_restricted(parent)?;
let mut temp = tempfile::Builder::new()
.prefix(".openshell-")
.tempfile_in(parent)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create temp file in {}", parent.display()))?;

std::io::Write::write_all(&mut temp, contents)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write temp file for {}", path.display()))?;
temp.as_file()
.sync_all()
.into_diagnostic()
.wrap_err_with(|| format!("failed to sync temp file for {}", path.display()))?;
set_file_owner_only(temp.path())?;
temp.persist(path)
.map_err(|err| err.error)
.into_diagnostic()
.wrap_err_with(|| format!("failed to rename temp file into {}", path.display()))?;
set_file_owner_only(path)?;
Ok(())
}

/// Check whether a file has permissions that are too open (group/other readable).
///
/// Returns `true` if the file has group or other read/write/execute bits set.
Expand Down Expand Up @@ -180,6 +212,22 @@ mod tests {
assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}");
}

#[test]
fn write_file_owner_only_atomic_replaces_contents() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("nested").join("secret");
write_file_owner_only_atomic(&file, b"first\n").unwrap();
write_file_owner_only_atomic(&file, b"second\n").unwrap();

assert_eq!(std::fs::read_to_string(&file).unwrap(), "second\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}");
}
}

#[cfg(unix)]
#[test]
fn is_file_permissions_too_open_detects_world_readable() {
Expand Down
84 changes: 75 additions & 9 deletions crates/openshell-core/src/sandbox_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,87 @@ pub const TLS_CERT: &str = "OPENSHELL_TLS_CERT";
/// Path to the private key for mTLS communication with the gateway.
pub const TLS_KEY: &str = "OPENSHELL_TLS_KEY";

/// Raw gateway-minted JWT identifying this sandbox. Mutually exclusive with
/// [`SANDBOX_TOKEN_FILE`] / [`K8S_SA_TOKEN_FILE`]; used only by test harnesses
/// that bypass the file-mount path.
/// Selects how the supervisor bootstraps sandbox authentication and who owns
/// token refresh.
pub const SANDBOX_AUTH_MODE: &str = "OPENSHELL_SANDBOX_AUTH_MODE";

/// Explicit sandbox authentication modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxAuthMode {
/// Use [`SANDBOX_TOKEN`] as a static gateway JWT.
///
/// This is intended for direct test/debug harnesses. The supervisor does
/// not refresh the token.
StaticToken,

/// Use [`SANDBOX_TOKEN_FILE`] as a gateway-managed token file.
///
/// Docker and Podman use this mode. The gateway refreshes the host-side
/// file and the supervisor re-reads it on outbound calls.
GatewayManagedFile,

/// Use [`SANDBOX_TOKEN_FILE`] as a supervisor-writable token file.
///
/// The VM driver uses this mode. The gateway injects a fresh token into
/// persisted VM state on resume and pushes live token updates over the
/// supervisor control stream.
GatewayManagedSupervisorPush,

/// Use [`K8S_SA_TOKEN_FILE`] to exchange Kubernetes workload identity for
/// a gateway JWT.
///
/// The supervisor re-exchanges the projected `ServiceAccount` token when
/// the gateway JWT needs rotation.
KubernetesServiceAccountExchange,
}

impl SandboxAuthMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::StaticToken => "static-token",
Self::GatewayManagedFile => "gateway-managed-file",
Self::GatewayManagedSupervisorPush => "gateway-managed-supervisor-push",
Self::KubernetesServiceAccountExchange => "kubernetes-service-account-exchange",
}
}

#[must_use]
pub fn allowed_values() -> &'static str {
"static-token, gateway-managed-file, gateway-managed-supervisor-push, kubernetes-service-account-exchange"
}
}

impl std::str::FromStr for SandboxAuthMode {
type Err = String;

fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"static-token" => Ok(Self::StaticToken),
"gateway-managed-file" => Ok(Self::GatewayManagedFile),
"gateway-managed-supervisor-push" => Ok(Self::GatewayManagedSupervisorPush),
"kubernetes-service-account-exchange" => Ok(Self::KubernetesServiceAccountExchange),
other => Err(format!(
"invalid sandbox auth mode '{other}' (expected one of: {})",
Self::allowed_values()
)),
}
}
}

/// Raw gateway-minted JWT identifying this sandbox. Used only when
/// [`SANDBOX_AUTH_MODE`] is [`SandboxAuthMode::StaticToken`].
pub const SANDBOX_TOKEN: &str = "OPENSHELL_SANDBOX_TOKEN";

/// Path to the file holding a gateway-minted sandbox JWT.
///
/// Set by the Docker, Podman, and VM drivers, which write the token to a
/// bundle file at sandbox-create time. Read once at supervisor startup;
/// the token is held in process memory thereafter.
/// Set by Docker, Podman, and VM when [`SANDBOX_AUTH_MODE`] is
/// [`SandboxAuthMode::GatewayManagedFile`] or
/// [`SandboxAuthMode::GatewayManagedSupervisorPush`].
pub const SANDBOX_TOKEN_FILE: &str = "OPENSHELL_SANDBOX_TOKEN_FILE";

/// Path to the projected `ServiceAccount` JWT (Kubernetes driver).
///
/// Used to bootstrap a gateway-minted JWT via `IssueSandboxToken`. Kubelet
/// writes and rotates this file; the supervisor exchanges its contents
/// for a gateway JWT at startup and on refresh.
/// Used when [`SANDBOX_AUTH_MODE`] is
/// [`SandboxAuthMode::KubernetesServiceAccountExchange`].
pub const K8S_SA_TOKEN_FILE: &str = "OPENSHELL_K8S_SA_TOKEN_FILE";
2 changes: 2 additions & 0 deletions crates/openshell-driver-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ overwrites security-critical keys:
- `OPENSHELL_SANDBOX`
- `OPENSHELL_SSH_SOCKET_PATH`
- `OPENSHELL_SANDBOX_COMMAND`
- `OPENSHELL_SANDBOX_AUTH_MODE=gateway-managed-file` and
`OPENSHELL_SANDBOX_TOKEN_FILE` when gateway JWT auth is enabled
- TLS path variables when HTTPS is enabled

Do not allow sandbox images or templates to override these values.
Loading
Loading