diff --git a/Cargo.lock b/Cargo.lock index 98797cc24..fd3b68d1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2507,6 +2507,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2912,7 +2922,7 @@ dependencies = [ "miette", "openshell-core", "serde", - "serde_yaml", + "serde_yml", ] [[package]] @@ -2932,7 +2942,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yaml", + "serde_yml", "tempfile", "thiserror 2.0.18", "tokio", @@ -2968,7 +2978,7 @@ dependencies = [ "rustls-pemfile", "seccompiler", "serde_json", - "serde_yaml", + "serde_yml", "sha2 0.10.9", "temp-env", "tempfile", @@ -4352,6 +4362,21 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "serdect" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 83ee24d9a..3380e040b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term"] # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +serde_yml = "0.0.12" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index 4130a65f6..33540e3f3 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -245,7 +245,7 @@ For the target daemon (local or remote): After the container starts: -1. **Clean stale nodes**: `clean_stale_nodes()` finds `NotReady` nodes via `kubectl get nodes` and deletes them. This is needed when a container is recreated but reuses the persistent volume -- k3s registers a new node (using the container ID as hostname) while old node entries persist in etcd. Non-fatal on error; returns the count of removed nodes. +1. **Clean stale nodes**: `clean_stale_nodes()` finds nodes whose name does not match the deterministic k3s `--node-name` and deletes them. That node name is derived from the gateway name but normalized to a Kubernetes-safe lowercase form so existing gateway names that contain `_`, `.`, or uppercase characters still produce a valid node identity. This cleanup is needed when a container is recreated but reuses the persistent volume -- old node entries can persist in etcd. Non-fatal on error; returns the count of removed nodes. 2. **Push local images** (optional, local deploy only): If `OPENSHELL_PUSH_IMAGES` is set, the comma-separated image refs are exported from the local Docker daemon as a single tar, uploaded into the container via `docker put_archive`, and imported into containerd via `ctr images import` in the `k8s.io` namespace. After import, `kubectl rollout restart deployment/openshell openshell` is run, followed by `kubectl rollout status --timeout=180s` to wait for completion. See `crates/openshell-bootstrap/src/push.rs`. 3. **Wait for gateway health**: `wait_for_gateway_ready()` polls the Docker HEALTHCHECK status up to 180 times, 2 seconds apart (6 min total). A background task streams container logs during this wait. Failure modes: - Container exits during polling: error includes recent log lines. diff --git a/architecture/gateway.md b/architecture/gateway.md index 39f97c8c1..72574410d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -501,7 +501,7 @@ The Helm chart template is at `deploy/helm/openshell/templates/statefulset.yaml` `SandboxClient` (`crates/openshell-server/src/sandbox/mod.rs`) manages `agents.x-k8s.io/v1alpha1/Sandbox` CRDs. -- **Create**: Translates a `Sandbox` proto into a Kubernetes `DynamicObject` with labels (`openshell.ai/sandbox-id`, `openshell.ai/managed-by: openshell`) and a spec that includes the pod template, environment variables, and gateway-required env vars (`OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_LISTEN_ADDR`, etc.). +- **Create**: Translates a `Sandbox` proto into a Kubernetes `DynamicObject` with labels (`openshell.ai/sandbox-id`, `openshell.ai/managed-by: openshell`) and a spec that includes the pod template, environment variables, and gateway-required env vars (`OPENSHELL_SANDBOX_ID`, `OPENSHELL_ENDPOINT`, `OPENSHELL_SSH_LISTEN_ADDR`, etc.). When callers do not provide custom `volumeClaimTemplates`, the server injects a default `workspace` PVC and mounts it at `/sandbox` so the default sandbox home/workdir survives pod rescheduling. - **Delete**: Calls the Kubernetes API to delete the CRD by name. Returns `false` if already gone (404). - **Pod IP resolution**: `agent_pod_ip()` fetches the agent pod and reads `status.podIP`. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index c870708dd..c5e212f85 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -24,7 +24,7 @@ All paths are relative to `crates/openshell-sandbox/src/`. | `sandbox/mod.rs` | Platform abstraction -- dispatches to Linux or no-op | | `sandbox/linux/mod.rs` | Linux composition: Landlock then seccomp | | `sandbox/linux/landlock.rs` | Filesystem isolation via Landlock LSM (ABI V1) | -| `sandbox/linux/seccomp.rs` | Syscall filtering via BPF on `SYS_socket` | +| `sandbox/linux/seccomp.rs` | Syscall filtering via BPF: socket domain blocks, dangerous syscall blocks, conditional flag blocks | | `bypass_monitor.rs` | Background `/dev/kmsg` reader for iptables bypass detection events | | `sandbox/linux/netns.rs` | Network namespace creation, veth pair setup, bypass detection iptables rules, cleanup on drop | | `l7/mod.rs` | L7 types (`L7Protocol`, `TlsMode`, `EnforcementMode`, `L7EndpointConfig`), config parsing, validation, access preset expansion, deprecated `tls` value handling | @@ -451,13 +451,7 @@ Kernel-level error behavior (e.g., Landlock ABI unavailable) depends on `Landloc **File:** `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs` -Seccomp blocks socket creation for specific address families. The filter targets a single syscall (`SYS_socket`) and inspects argument 0 (the domain). - -**Always blocked** (regardless of network mode): -- `AF_NETLINK`, `AF_PACKET`, `AF_BLUETOOTH`, `AF_VSOCK` - -**Additionally blocked in `Block` mode** (no proxy): -- `AF_INET`, `AF_INET6` +Seccomp provides three layers of syscall restriction: socket domain blocks, unconditional syscall blocks, and conditional syscall blocks. The filter uses a default-allow policy (`SeccompAction::Allow`) with targeted rules that return `Errno(EPERM)`. **Skipped entirely** in `Allow` mode. @@ -465,8 +459,44 @@ Setup: 1. `prctl(PR_SET_NO_NEW_PRIVS, 1)` -- required before seccomp 2. `seccompiler::apply_filter()` with default action `Allow` and per-rule action `Errno(EPERM)` +#### Socket domain blocks + +| Domain | Always blocked | Additionally blocked in Block mode | +|--------|:-:|:-:| +| `AF_PACKET` | Yes | | +| `AF_BLUETOOTH` | Yes | | +| `AF_VSOCK` | Yes | | +| `AF_INET` | | Yes | +| `AF_INET6` | | Yes | +| `AF_NETLINK` | | Yes | + In `Proxy` mode, `AF_INET`/`AF_INET6` are allowed because the sandboxed process needs to connect to the proxy over the veth pair. The network namespace ensures it can only reach the proxy's IP (`10.200.0.1`). +#### Unconditional syscall blocks + +These syscalls are blocked entirely (EPERM for any invocation): + +| Syscall | Reason | +|---------|--------| +| `memfd_create` | Fileless binary execution bypasses Landlock filesystem restrictions | +| `ptrace` | Cross-process memory inspection and code injection | +| `bpf` | Kernel BPF program loading | +| `process_vm_readv` | Cross-process memory read | +| `io_uring_setup` | Async I/O subsystem with extensive CVE history | +| `mount` | Filesystem mount could subvert Landlock or overlay writable paths | + +#### Conditional syscall blocks + +These syscalls are only blocked when specific flag patterns are present: + +| Syscall | Condition | Reason | +|---------|-----------|--------| +| `execveat` | `AT_EMPTY_PATH` flag set (arg4) | Fileless execution from an anonymous fd | +| `unshare` | `CLONE_NEWUSER` flag set (arg0) | User namespace creation enables privilege escalation | +| `seccomp` | operation == `SECCOMP_SET_MODE_FILTER` (arg0) | Prevents sandboxed code from replacing the active filter | + +Conditional blocks use `MaskedEq` for flag checks (bit-test) and `Eq` for exact-value matches. This allows normal use of these syscalls while blocking the dangerous flag combinations. + ### Network namespace isolation **File:** `crates/openshell-sandbox/src/sandbox/linux/netns.rs` diff --git a/architecture/security-policy.md b/architecture/security-policy.md index 555ba67a5..01eb96f94 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -850,6 +850,10 @@ The response includes an `X-OpenShell-Policy` header and `Connection: close`. Se ## Seccomp Filter Details +The seccomp filter uses a default-allow policy (`SeccompAction::Allow`) with targeted rules that return `EPERM`. It provides three layers of protection: socket domain blocks, unconditional syscall blocks, and conditional syscall blocks. See `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs`. + +### Blocked socket domains + Regardless of network mode, certain socket domains are always blocked: | Domain | Constant | Reason | @@ -861,7 +865,30 @@ Regardless of network mode, certain socket domains are always blocked: In proxy mode (which is always active), `AF_INET` (2) and `AF_INET6` (10) are allowed so the sandbox process can reach the proxy. -The seccomp filter uses a default-allow policy (`SeccompAction::Allow`) with specific `socket()` syscall rules that return `EPERM` when the first argument (domain) matches a blocked value. See `crates/openshell-sandbox/src/sandbox/linux/seccomp.rs`. +### Blocked syscalls + +These syscalls are blocked unconditionally (EPERM for any invocation): + +| Syscall | NR (x86-64) | Reason | +|---------|-------------|--------| +| `memfd_create` | 319 | Fileless binary execution bypasses Landlock filesystem restrictions | +| `ptrace` | 101 | Cross-process memory inspection and code injection | +| `bpf` | 321 | Kernel BPF program loading | +| `process_vm_readv` | 310 | Cross-process memory read | +| `io_uring_setup` | 425 | Async I/O subsystem with extensive CVE history | +| `mount` | 165 | Filesystem mount could subvert Landlock or overlay writable paths | + +### Conditionally blocked syscalls + +These syscalls are blocked only when specific flag patterns are present in their arguments: + +| Syscall | NR (x86-64) | Condition | Reason | +|---------|-------------|-----------|--------| +| `execveat` | 322 | `AT_EMPTY_PATH` (0x1000) set in flags (arg4) | Fileless execution from an anonymous fd | +| `unshare` | 272 | `CLONE_NEWUSER` (0x10000000) set in flags (arg0) | User namespace creation enables privilege escalation | +| `seccomp` | 317 | operation == `SECCOMP_SET_MODE_FILTER` (1) in arg0 | Prevents sandboxed code from replacing the active filter | + +Flag checks use `MaskedEq` (`(arg & mask) == mask`) to detect the flag bit regardless of other bits. The `seccomp` syscall check uses `Eq` for exact value comparison on the operation argument. --- diff --git a/crates/openshell-bootstrap/src/constants.rs b/crates/openshell-bootstrap/src/constants.rs index 74e381fd2..eee9000d1 100644 --- a/crates/openshell-bootstrap/src/constants.rs +++ b/crates/openshell-bootstrap/src/constants.rs @@ -13,11 +13,66 @@ pub const SERVER_CLIENT_CA_SECRET_NAME: &str = "openshell-server-client-ca"; pub const CLIENT_TLS_SECRET_NAME: &str = "openshell-client-tls"; /// K8s secret holding the SSH handshake HMAC secret (shared by gateway and sandbox pods). pub const SSH_HANDSHAKE_SECRET_NAME: &str = "openshell-ssh-handshake"; +const NODE_NAME_PREFIX: &str = "openshell-"; +const NODE_NAME_FALLBACK_SUFFIX: &str = "gateway"; +const KUBERNETES_MAX_NAME_LEN: usize = 253; pub fn container_name(name: &str) -> String { format!("openshell-cluster-{name}") } +/// Deterministic k3s node name derived from the gateway name. +/// +/// k3s defaults to using the container hostname (= Docker container ID) as +/// the node name. When the container is recreated (e.g. after an image +/// upgrade), the container ID changes, creating a new k3s node. The +/// `clean_stale_nodes` function then deletes PVCs whose backing PVs have +/// node affinity for the old node — wiping the server database and any +/// sandbox persistent volumes. +/// +/// By passing a deterministic `--node-name` to k3s, the node identity +/// survives container recreation, and PVCs are never orphaned. +/// +/// Gateway names allow Docker-friendly separators and uppercase characters, +/// but Kubernetes node names must be DNS-safe. Normalize the gateway name into +/// a single lowercase RFC 1123 label so previously accepted names such as +/// `prod_us` or `Prod.US` still deploy successfully. +pub fn node_name(name: &str) -> String { + format!("{NODE_NAME_PREFIX}{}", normalize_node_name_suffix(name)) +} + +fn normalize_node_name_suffix(name: &str) -> String { + let mut normalized = String::with_capacity(name.len()); + let mut last_was_separator = false; + + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + last_was_separator = false; + } else if !last_was_separator { + normalized.push('-'); + last_was_separator = true; + } + } + + let mut normalized = normalized.trim_matches('-').to_string(); + if normalized.is_empty() { + normalized.push_str(NODE_NAME_FALLBACK_SUFFIX); + } + + let max_suffix_len = KUBERNETES_MAX_NAME_LEN.saturating_sub(NODE_NAME_PREFIX.len()); + if normalized.len() > max_suffix_len { + normalized.truncate(max_suffix_len); + normalized.truncate(normalized.trim_end_matches('-').len()); + } + + if normalized.is_empty() { + normalized.push_str(NODE_NAME_FALLBACK_SUFFIX); + } + + normalized +} + pub fn volume_name(name: &str) -> String { format!("openshell-cluster-{name}") } @@ -25,3 +80,33 @@ pub fn volume_name(name: &str) -> String { pub fn network_name(name: &str) -> String { format!("openshell-cluster-{name}") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_name_normalizes_uppercase_and_underscores() { + assert_eq!(node_name("Prod_US"), "openshell-prod-us"); + } + + #[test] + fn node_name_collapses_and_trims_separator_runs() { + assert_eq!(node_name("._Prod..__-Gateway-."), "openshell-prod-gateway"); + } + + #[test] + fn node_name_falls_back_when_gateway_name_has_no_alphanumerics() { + assert_eq!(node_name("...___---"), "openshell-gateway"); + } + + #[test] + fn node_name_truncates_to_kubernetes_name_limit() { + let gateway_name = "A".repeat(400); + let node_name = node_name(&gateway_name); + + assert!(node_name.len() <= KUBERNETES_MAX_NAME_LEN); + assert!(node_name.starts_with(NODE_NAME_PREFIX)); + assert!(node_name.ends_with('a')); + } +} diff --git a/crates/openshell-bootstrap/src/container_runtime.rs b/crates/openshell-bootstrap/src/container_runtime.rs index 855f76f5c..86d7c337f 100644 --- a/crates/openshell-bootstrap/src/container_runtime.rs +++ b/crates/openshell-bootstrap/src/container_runtime.rs @@ -203,6 +203,16 @@ fn podman_rootless_socket_path() -> Option { Some(format!("{runtime_dir}/podman/podman.sock")) } +/// Check whether the current process is running as a non-root user. +/// +/// Returns `true` when the effective UID is non-zero (rootless mode). +/// Used to decide container configuration — for example, rootless Podman +/// needs a private cgroup namespace while rootful Podman (and Docker) can +/// use the host cgroup namespace. +pub(crate) fn is_rootless() -> bool { + current_uid().map_or(false, |uid| uid != 0) +} + /// Get the current user's UID by reading `/proc/self/status`. /// /// Returns `None` on non-Linux systems or if the file cannot be parsed. diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index 771c28d95..ff29bbd53 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::RemoteOptions; -use crate::constants::{container_name, network_name, volume_name}; +use crate::constants::{container_name, network_name, node_name, volume_name}; use crate::container_runtime::{ ALL_HOST_GATEWAY_ALIASES, ContainerRuntime, DOCKER_HOST_GATEWAY_ALIAS, PODMAN_HOST_GATEWAY_ALIAS, find_all_sockets, @@ -555,6 +555,7 @@ pub async fn ensure_container( registry_token: Option<&str>, device_ids: &[String], runtime: ContainerRuntime, + resume: bool, ) -> Result { let container_name = container_name(name); @@ -564,25 +565,34 @@ pub async fn ensure_container( .await { Ok(info) => { - // Container exists — verify it is using the expected image. - // Resolve the desired image ref to its content-addressable ID so we - // can compare against the container's image field (which Docker - // stores as an ID). - let desired_id = docker - .inspect_image(image_ref) - .await - .ok() - .and_then(|img| img.id); + // On resume we always reuse the existing container — the persistent + // volume holds k3s etcd state, and recreating the container with + // different env vars would cause the entrypoint to rewrite the + // HelmChart manifest, triggering a Helm upgrade that changes the + // StatefulSet image reference while the old pod still runs with the + // previous image. Reusing the container avoids this entirely. + // + // On a non-resume path we check whether the image changed and + // recreate only when necessary. + let reuse = if resume { + true + } else { + let desired_id = docker + .inspect_image(image_ref) + .await + .ok() + .and_then(|img| img.id); - let container_image_id = info.image; + let container_image_id = info.image.clone(); - let image_matches = match (&desired_id, &container_image_id) { - (Some(desired), Some(current)) => desired == current, - _ => false, + match (&desired_id, &container_image_id) { + (Some(desired), Some(current)) => desired == current, + _ => false, + } }; - if image_matches { - // The container exists with the correct image, but its network + if reuse { + // The container exists and should be reused. Its network // attachment may be stale. When the gateway is resumed after a // container kill, `ensure_network` destroys and recreates the // Docker network (giving it a new ID). The stopped container @@ -616,8 +626,8 @@ pub async fn ensure_container( tracing::info!( "Container {} exists but uses a different image (container={}, desired={}), recreating", container_name, - container_image_id.as_deref().map_or("unknown", truncate_id), - desired_id.as_deref().map_or("unknown", truncate_id), + info.image.as_deref().map_or("unknown", truncate_id), + image_ref, ); let _ = docker.stop_container(&container_name, None).await; @@ -654,7 +664,11 @@ pub async fn ensure_container( // Rootless Podman with cgroupns=host mounts the host cgroup tree // read-only (user namespace restriction), so it needs a private cgroup // namespace where the delegated controllers are writable. - let cgroupns = if runtime == ContainerRuntime::Podman { + // Rootful Podman (uid 0) can use host cgroupns just like Docker since + // root has full write access to the host cgroup tree. + let is_rootless_podman = + runtime == ContainerRuntime::Podman && crate::container_runtime::is_rootless(); + let cgroupns = if is_rootless_podman { HostConfigCgroupnsModeEnum::PRIVATE } else { HostConfigCgroupnsModeEnum::HOST @@ -767,6 +781,11 @@ pub async fn ensure_container( format!("REGISTRY_HOST={registry_host}"), format!("REGISTRY_INSECURE={registry_insecure}"), format!("IMAGE_REPO_BASE={image_repo_base}"), + // Deterministic k3s node name so the node identity survives container + // recreation (e.g. after an image upgrade). Without this, k3s uses + // the container ID as the hostname/node name, which changes on every + // container recreate and triggers stale-node PVC cleanup. + format!("OPENSHELL_NODE_NAME={}", node_name(name)), ]; if let Some(endpoint) = registry_endpoint { env_vars.push(format!("REGISTRY_ENDPOINT={endpoint}")); @@ -852,6 +871,14 @@ pub async fn ensure_container( let config = ContainerCreateBody { image: Some(image_ref.to_string()), + // Set the container hostname to the deterministic node name. + // k3s uses the container hostname as its default node name. Without + // this, Docker defaults to the container ID (first 12 hex chars), + // which changes on every container recreation and can cause + // `clean_stale_nodes` to delete the wrong node on resume. The + // hostname persists across container stop/start cycles, ensuring a + // stable node identity. + hostname: Some(node_name(name)), cmd: Some(cmd), env, exposed_ports: Some(exposed_ports), diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 031399934..3eb78ab8d 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -336,6 +336,7 @@ where // idempotent and will reuse the volume, create a container if needed, // and start it) let mut resume = false; + let mut resume_container_exists = false; if let Some(existing) = check_existing_gateway(&target_docker, &name).await? { if recreate { log("[status] Removing existing gateway".to_string()); @@ -343,39 +344,51 @@ where } else if existing.container_running { log("[status] Gateway is already running".to_string()); resume = true; + resume_container_exists = true; } else { log("[status] Resuming gateway from existing state".to_string()); resume = true; + resume_container_exists = existing.container_exists; } } - // Ensure the image is available on the target Docker daemon - if remote_opts.is_some() { - log("[status] Downloading gateway".to_string()); - let on_log_clone = Arc::clone(&on_log); - let progress_cb = move |msg: String| { - if let Ok(mut f) = on_log_clone.lock() { - f(msg); - } - }; - image::pull_remote_image( - &target_docker, - &image_ref, - registry_username.as_deref(), - registry_token.as_deref(), - progress_cb, - ) - .await?; - } else { - // Local deployment: ensure image exists (pull if needed) - log("[status] Downloading gateway".to_string()); - ensure_image( - &target_docker, - &image_ref, - registry_username.as_deref(), - registry_token.as_deref(), - ) - .await?; + // Ensure the image is available on the target Docker daemon. + // When both the container and volume exist we can skip the pull entirely + // — the container already references a valid local image. This avoids + // failures when the original image tag (e.g. a local-only + // `openshell/cluster:dev`) is not available from the default registry. + // + // When only the volume survives (container was removed), we still need + // the image to recreate the container, so the pull must happen. + let need_image = !resume || !resume_container_exists; + if need_image { + if remote_opts.is_some() { + log("[status] Downloading gateway".to_string()); + let on_log_clone = Arc::clone(&on_log); + let progress_cb = move |msg: String| { + if let Ok(mut f) = on_log_clone.lock() { + f(msg); + } + }; + image::pull_remote_image( + &target_docker, + &image_ref, + registry_username.as_deref(), + registry_token.as_deref(), + progress_cb, + ) + .await?; + } else { + // Local deployment: ensure image exists (pull if needed) + log("[status] Downloading gateway".to_string()); + ensure_image( + &target_docker, + &image_ref, + registry_username.as_deref(), + registry_token.as_deref(), + ) + .await?; + } } // All subsequent operations use the target Docker (remote or local) @@ -467,6 +480,7 @@ where registry_token.as_deref(), &device_ids, runtime, + resume, ) .await?; let port = actual_port; diff --git a/crates/openshell-bootstrap/src/runtime.rs b/crates/openshell-bootstrap/src/runtime.rs index 2a10b2651..0f9a96e6b 100644 --- a/crates/openshell-bootstrap/src/runtime.rs +++ b/crates/openshell-bootstrap/src/runtime.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::constants::{KUBECONFIG_PATH, container_name}; +use crate::constants::{KUBECONFIG_PATH, container_name, node_name}; use bollard::Docker; use bollard::container::LogOutput; use bollard::exec::CreateExecOptions; @@ -385,11 +385,19 @@ pub async fn clean_stale_nodes(docker: &Docker, name: &str) -> Result { let container_name = container_name(name); let mut stale_nodes: Vec = Vec::new(); + // Determine the current node name. With the deterministic `--node-name` + // entrypoint change the k3s node is `openshell-{gateway}`. However, older + // cluster images (built before that change) still use the container hostname + // (= Docker container ID) as the node name. We must handle both: + // + // 1. If the expected deterministic name appears in the node list, use it. + // 2. Otherwise fall back to the container hostname (old behaviour). + // + // This ensures backward compatibility during upgrades where the bootstrap + // CLI is newer than the cluster image. + let deterministic_node = node_name(name); + for attempt in 1..=MAX_ATTEMPTS { - // List ALL node names and the container's own hostname. Any node that - // is not the current container is stale — we cannot rely on the Ready - // condition because k3s may not have marked the old node NotReady yet - // when this runs shortly after container start. let (output, exit_code) = exec_capture_with_exit( docker, &container_name, @@ -406,16 +414,27 @@ pub async fn clean_stale_nodes(docker: &Docker, name: &str) -> Result { .await?; if exit_code == 0 { - // Determine the current node name (container hostname). - let (hostname_out, _) = - exec_capture_with_exit(docker, &container_name, vec!["hostname".to_string()]) - .await?; - let current_hostname = hostname_out.trim().to_string(); - - stale_nodes = output + let all_nodes: Vec<&str> = output .lines() .map(str::trim) - .filter(|l| !l.is_empty() && *l != current_hostname) + .filter(|l| !l.is_empty()) + .collect(); + + // Pick the current node identity: prefer the deterministic name, + // fall back to the container hostname for older cluster images. + let current_node = if all_nodes.contains(&deterministic_node.as_str()) { + deterministic_node.clone() + } else { + // Older cluster image without --node-name: read hostname. + let (hostname_out, _) = + exec_capture_with_exit(docker, &container_name, vec!["hostname".to_string()]) + .await?; + hostname_out.trim().to_string() + }; + + stale_nodes = all_nodes + .into_iter() + .filter(|n| *n != current_node) .map(ToString::to_string) .collect(); break; diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 0d546c7b1..d1cd7fd69 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1239,6 +1239,48 @@ enum SandboxCommands { all: bool, }, + /// Execute a command in a running sandbox. + /// + /// Runs a command inside an existing sandbox using the gRPC exec endpoint. + /// Output is streamed to the terminal in real-time. The CLI exits with the + /// remote command's exit code. + /// + /// For interactive shell sessions, use `sandbox connect` instead. + /// + /// Examples: + /// openshell sandbox exec --name my-sandbox -- ls -la /workspace + /// openshell sandbox exec -n my-sandbox --workdir /app -- python script.py + /// echo "hello" | openshell sandbox exec -n my-sandbox -- cat + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Exec { + /// Sandbox name (defaults to last-used sandbox). + #[arg(long, short = 'n', add = ArgValueCompleter::new(completers::complete_sandbox_names))] + name: Option, + + /// Working directory inside the sandbox. + #[arg(long)] + workdir: Option, + + /// Timeout in seconds (0 = no timeout). + #[arg(long, default_value_t = 0)] + timeout: u32, + + /// Allocate a pseudo-terminal for the remote command. + /// Defaults to auto-detection (on when stdin and stdout are terminals). + /// Use --tty to force a PTY even when auto-detection fails, or + /// --no-tty to disable. + #[arg(long, overrides_with = "no_tty")] + tty: bool, + + /// Disable pseudo-terminal allocation. + #[arg(long, overrides_with = "tty")] + no_tty: bool, + + /// Command and arguments to execute. + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, + }, + /// Connect to a sandbox. /// /// When no name is given, reconnects to the last-used sandbox. @@ -2324,6 +2366,38 @@ async fn main() -> Result<()> { } let _ = save_last_sandbox(&ctx.name, &name); } + SandboxCommands::Exec { + name, + workdir, + timeout, + tty, + no_tty, + command, + } => { + let name = resolve_sandbox_name(name, &ctx.name)?; + // Resolve --tty / --no-tty into an Option override. + let tty_override = if no_tty { + Some(false) + } else if tty { + Some(true) + } else { + None // auto-detect + }; + let exit_code = run::sandbox_exec_grpc( + endpoint, + &name, + &command, + workdir.as_deref(), + timeout, + tty_override, + &tls, + ) + .await?; + let _ = save_last_sandbox(&ctx.name, &name); + if exit_code != 0 { + std::process::exit(exit_code); + } + } SandboxCommands::SshConfig { name } => { let name = resolve_sandbox_name(name, &ctx.name)?; run::print_ssh_config(&ctx.name, &name); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index c0df6bd45..351a9346e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -24,13 +24,13 @@ use openshell_bootstrap::{ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, - GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, + ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest, - UpdateProviderRequest, WatchSandboxRequest, setting_value, + UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_providers::{ @@ -38,7 +38,7 @@ use openshell_providers::{ }; use owo_colors::OwoColorize; use std::collections::{HashMap, HashSet, VecDeque}; -use std::io::{IsTerminal, Write}; +use std::io::{IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; @@ -2724,6 +2724,116 @@ pub async fn sandbox_get(server: &str, name: &str, tls: &TlsOptions) -> Result<( Ok(()) } +/// Maximum stdin payload size (4 MiB). Prevents the CLI from reading unbounded +/// data into memory before the server rejects an oversized message. +const MAX_STDIN_PAYLOAD: usize = 4 * 1024 * 1024; + +/// Execute a command in a running sandbox via gRPC, streaming output to the terminal. +/// +/// Returns the remote command's exit code. +pub async fn sandbox_exec_grpc( + server: &str, + name: &str, + command: &[String], + workdir: Option<&str>, + timeout_seconds: u32, + tty_override: Option, + tls: &TlsOptions, +) -> Result { + let mut client = grpc_client(server, tls).await?; + + // Resolve sandbox name to id. + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + // Verify the sandbox is ready before issuing the exec. + if SandboxPhase::try_from(sandbox.phase) != Ok(SandboxPhase::Ready) { + return Err(miette::miette!( + "sandbox '{}' is not ready (phase: {}); wait for it to reach Ready state", + name, + phase_name(sandbox.phase) + )); + } + + // Read stdin if piped (not a TTY), using spawn_blocking to avoid blocking + // the async runtime. Cap the read at MAX_STDIN_PAYLOAD + 1 so we never + // buffer more than the limit into memory. + let stdin_payload = if !std::io::stdin().is_terminal() { + tokio::task::spawn_blocking(|| { + let limit = (MAX_STDIN_PAYLOAD + 1) as u64; + let mut buf = Vec::new(); + std::io::stdin() + .take(limit) + .read_to_end(&mut buf) + .into_diagnostic()?; + if buf.len() > MAX_STDIN_PAYLOAD { + return Err(miette::miette!( + "stdin payload exceeds {} byte limit; pipe smaller inputs or use `sandbox upload`", + MAX_STDIN_PAYLOAD + )); + } + Ok(buf) + }) + .await + .into_diagnostic()?? // first ? unwraps JoinError, second ? unwraps Result + } else { + Vec::new() + }; + + // Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect. + let tty = tty_override + .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal()); + + // Make the streaming gRPC call. + let mut stream = client + .exec_sandbox(ExecSandboxRequest { + sandbox_id: sandbox.id, + command: command.to_vec(), + workdir: workdir.unwrap_or_default().to_string(), + environment: HashMap::new(), + timeout_seconds, + stdin: stdin_payload, + tty, + }) + .await + .into_diagnostic()? + .into_inner(); + + // Stream output to terminal in real-time. + let mut exit_code = 0i32; + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + + while let Some(event) = stream.next().await { + let event = event.into_diagnostic()?; + match event.payload { + Some(exec_sandbox_event::Payload::Stdout(out)) => { + let mut handle = stdout.lock(); + handle.write_all(&out.data).into_diagnostic()?; + handle.flush().into_diagnostic()?; + } + Some(exec_sandbox_event::Payload::Stderr(err)) => { + let mut handle = stderr.lock(); + handle.write_all(&err.data).into_diagnostic()?; + handle.flush().into_diagnostic()?; + } + Some(exec_sandbox_event::Payload::Exit(exit)) => { + exit_code = exit.exit_code; + } + None => {} + } + } + + Ok(exit_code) +} + /// Print a single YAML line with dimmed keys and regular values. fn print_yaml_line(line: &str) { // Find leading whitespace diff --git a/crates/openshell-core/src/proto/openshell.datamodel.v1.rs b/crates/openshell-core/src/proto/openshell.datamodel.v1.rs deleted file mode 100644 index 310497d1a..000000000 --- a/crates/openshell-core/src/proto/openshell.datamodel.v1.rs +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// This file is @generated by prost-build. -/// Sandbox model stored by OpenShell. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Sandbox { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub name: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub namespace: ::prost::alloc::string::String, - #[prost(message, optional, tag = "4")] - pub spec: ::core::option::Option, - #[prost(message, optional, tag = "5")] - pub status: ::core::option::Option, - #[prost(enumeration = "SandboxPhase", tag = "6")] - pub phase: i32, -} -/// OpenShell-level sandbox spec. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxSpec { - #[prost(string, tag = "1")] - pub log_level: ::prost::alloc::string::String, - #[prost(map = "string, string", tag = "5")] - pub environment: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, - #[prost(message, optional, tag = "6")] - pub template: ::core::option::Option, - /// Required sandbox policy configuration. - #[prost(message, optional, tag = "7")] - pub policy: ::core::option::Option, - /// Provider names to attach to this sandbox. - #[prost(string, repeated, tag = "8")] - pub providers: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, -} -/// Sandbox template mapped onto Kubernetes pod template inputs. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxTemplate { - #[prost(string, tag = "1")] - pub image: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub runtime_class_name: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub agent_socket: ::prost::alloc::string::String, - #[prost(map = "string, string", tag = "4")] - pub labels: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, - #[prost(map = "string, string", tag = "5")] - pub annotations: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, - #[prost(map = "string, string", tag = "6")] - pub environment: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, - #[prost(message, optional, tag = "7")] - pub resources: ::core::option::Option<::prost_types::Struct>, - #[prost(message, optional, tag = "9")] - pub volume_claim_templates: ::core::option::Option<::prost_types::Struct>, -} -/// Sandbox status captured from Kubernetes. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxStatus { - #[prost(string, tag = "1")] - pub sandbox_name: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub agent_pod: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub agent_fd: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub sandbox_fd: ::prost::alloc::string::String, - #[prost(message, repeated, tag = "5")] - pub conditions: ::prost::alloc::vec::Vec, -} -/// Sandbox condition mirrors Kubernetes conditions. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxCondition { - #[prost(string, tag = "1")] - pub r#type: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub status: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub reason: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub message: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub last_transition_time: ::prost::alloc::string::String, -} -/// Provider model stored by OpenShell. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct Provider { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub name: ::prost::alloc::string::String, - /// Canonical provider type slug (for example: "claude", "gitlab"). - #[prost(string, tag = "3")] - pub r#type: ::prost::alloc::string::String, - /// Secret values used for authentication. - #[prost(map = "string, string", tag = "4")] - pub credentials: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, - /// Non-secret provider configuration. - #[prost(map = "string, string", tag = "5")] - pub config: - ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, -} -/// High-level sandbox lifecycle phase. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum SandboxPhase { - Unspecified = 0, - Provisioning = 1, - Ready = 2, - Error = 3, - Deleting = 4, - Unknown = 5, -} -impl SandboxPhase { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "SANDBOX_PHASE_UNSPECIFIED", - Self::Provisioning => "SANDBOX_PHASE_PROVISIONING", - Self::Ready => "SANDBOX_PHASE_READY", - Self::Error => "SANDBOX_PHASE_ERROR", - Self::Deleting => "SANDBOX_PHASE_DELETING", - Self::Unknown => "SANDBOX_PHASE_UNKNOWN", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "SANDBOX_PHASE_UNSPECIFIED" => Some(Self::Unspecified), - "SANDBOX_PHASE_PROVISIONING" => Some(Self::Provisioning), - "SANDBOX_PHASE_READY" => Some(Self::Ready), - "SANDBOX_PHASE_ERROR" => Some(Self::Error), - "SANDBOX_PHASE_DELETING" => Some(Self::Deleting), - "SANDBOX_PHASE_UNKNOWN" => Some(Self::Unknown), - _ => None, - } - } -} diff --git a/crates/openshell-core/src/proto/openshell.sandbox.v1.rs b/crates/openshell-core/src/proto/openshell.sandbox.v1.rs deleted file mode 100644 index c7fbb178b..000000000 --- a/crates/openshell-core/src/proto/openshell.sandbox.v1.rs +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// This file is @generated by prost-build. -/// Sandbox security policy configuration. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxPolicy { - /// Policy version. - #[prost(uint32, tag = "1")] - pub version: u32, - /// Filesystem access policy. - #[prost(message, optional, tag = "2")] - pub filesystem: ::core::option::Option, - /// Network access policy. - #[prost(message, optional, tag = "3")] - pub network: ::core::option::Option, - /// Landlock configuration. - #[prost(message, optional, tag = "4")] - pub landlock: ::core::option::Option, - /// Process execution policy. - #[prost(message, optional, tag = "5")] - pub process: ::core::option::Option, -} -/// Filesystem access policy. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct FilesystemPolicy { - /// Read-only directory allow list. - #[prost(string, repeated, tag = "1")] - pub read_only: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Read-write directory allow list. - #[prost(string, repeated, tag = "2")] - pub read_write: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Automatically include the workdir as read-write. - #[prost(bool, tag = "3")] - pub include_workdir: bool, -} -/// Network access policy. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct NetworkPolicy { - /// Network access mode. - #[prost(enumeration = "NetworkMode", tag = "1")] - pub mode: i32, - /// Proxy configuration (required when mode is PROXY). - #[prost(message, optional, tag = "2")] - pub proxy: ::core::option::Option, -} -/// Proxy configuration for network policy. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ProxyPolicy { - /// Unix socket path for a local proxy (preferred for strict seccomp rules). - #[prost(string, tag = "1")] - pub unix_socket: ::prost::alloc::string::String, - /// TCP address for a local HTTP proxy (loopback-only). - #[prost(string, tag = "2")] - pub http_addr: ::prost::alloc::string::String, - /// Allowed hostnames for proxy traffic. Empty means allow all. - #[prost(string, repeated, tag = "3")] - pub allow_hosts: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, -} -/// Landlock policy configuration. -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct LandlockPolicy { - /// Compatibility mode. - #[prost(enumeration = "LandlockCompatibility", tag = "1")] - pub compatibility: i32, -} -/// Process execution policy. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ProcessPolicy { - /// User name to run the sandboxed process as. - #[prost(string, tag = "1")] - pub run_as_user: ::prost::alloc::string::String, - /// Group name to run the sandboxed process as. - #[prost(string, tag = "2")] - pub run_as_group: ::prost::alloc::string::String, -} -/// Request to get sandbox policy by sandbox ID. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetSandboxPolicyRequest { - /// The sandbox ID. - #[prost(string, tag = "1")] - pub sandbox_id: ::prost::alloc::string::String, -} -/// Response containing sandbox policy. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetSandboxPolicyResponse { - /// The sandbox policy configuration. - #[prost(message, optional, tag = "1")] - pub policy: ::core::option::Option, -} -/// Network access mode. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum NetworkMode { - /// Unspecified defaults to BLOCK. - Unspecified = 0, - /// Block all network access. - Block = 1, - /// Route traffic through a proxy. - Proxy = 2, - /// Allow all network access. - Allow = 3, -} -impl NetworkMode { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "NETWORK_MODE_UNSPECIFIED", - Self::Block => "NETWORK_MODE_BLOCK", - Self::Proxy => "NETWORK_MODE_PROXY", - Self::Allow => "NETWORK_MODE_ALLOW", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "NETWORK_MODE_UNSPECIFIED" => Some(Self::Unspecified), - "NETWORK_MODE_BLOCK" => Some(Self::Block), - "NETWORK_MODE_PROXY" => Some(Self::Proxy), - "NETWORK_MODE_ALLOW" => Some(Self::Allow), - _ => None, - } - } -} -/// Landlock compatibility mode. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum LandlockCompatibility { - /// Unspecified defaults to BEST_EFFORT. - Unspecified = 0, - /// Use best effort - degrade gracefully on older kernels. - BestEffort = 1, - /// Require full Landlock support or fail. - HardRequirement = 2, -} -impl LandlockCompatibility { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "LANDLOCK_COMPATIBILITY_UNSPECIFIED", - Self::BestEffort => "LANDLOCK_COMPATIBILITY_BEST_EFFORT", - Self::HardRequirement => "LANDLOCK_COMPATIBILITY_HARD_REQUIREMENT", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "LANDLOCK_COMPATIBILITY_UNSPECIFIED" => Some(Self::Unspecified), - "LANDLOCK_COMPATIBILITY_BEST_EFFORT" => Some(Self::BestEffort), - "LANDLOCK_COMPATIBILITY_HARD_REQUIREMENT" => Some(Self::HardRequirement), - _ => None, - } - } -} diff --git a/crates/openshell-core/src/proto/openshell.test.v1.rs b/crates/openshell-core/src/proto/openshell.test.v1.rs deleted file mode 100644 index 319b3fd3a..000000000 --- a/crates/openshell-core/src/proto/openshell.test.v1.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// This file is @generated by prost-build. -/// Simple object for persistence tests. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ObjectForTest { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - #[prost(string, tag = "2")] - pub name: ::prost::alloc::string::String, - #[prost(uint32, tag = "3")] - pub count: u32, -} diff --git a/crates/openshell-core/src/proto/openshell.v1.rs b/crates/openshell-core/src/proto/openshell.v1.rs deleted file mode 100644 index a2735b076..000000000 --- a/crates/openshell-core/src/proto/openshell.v1.rs +++ /dev/null @@ -1,1188 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// This file is @generated by prost-build. -/// Health check request. -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct HealthRequest {} -/// Health check response. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct HealthResponse { - /// Service status. - #[prost(enumeration = "ServiceStatus", tag = "1")] - pub status: i32, - /// Service version. - #[prost(string, tag = "2")] - pub version: ::prost::alloc::string::String, -} -/// Create sandbox request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CreateSandboxRequest { - #[prost(message, optional, tag = "1")] - pub spec: ::core::option::Option, - /// Optional user-supplied sandbox name. When empty the server generates one. - #[prost(string, tag = "2")] - pub name: ::prost::alloc::string::String, -} -/// Get sandbox request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct GetSandboxRequest { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, -} -/// List sandboxes request. -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct ListSandboxesRequest { - #[prost(uint32, tag = "1")] - pub limit: u32, - #[prost(uint32, tag = "2")] - pub offset: u32, -} -/// Delete sandbox request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DeleteSandboxRequest { - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, -} -/// Sandbox response. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxResponse { - #[prost(message, optional, tag = "1")] - pub sandbox: ::core::option::Option, -} -/// List sandboxes response. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct ListSandboxesResponse { - #[prost(message, repeated, tag = "1")] - pub sandboxes: ::prost::alloc::vec::Vec, -} -/// Delete sandbox response. -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct DeleteSandboxResponse { - #[prost(bool, tag = "1")] - pub deleted: bool, -} -/// Create SSH session request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CreateSshSessionRequest { - /// Sandbox id. - #[prost(string, tag = "1")] - pub sandbox_id: ::prost::alloc::string::String, -} -/// Create SSH session response. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct CreateSshSessionResponse { - /// Sandbox id. - #[prost(string, tag = "1")] - pub sandbox_id: ::prost::alloc::string::String, - /// Session token for the gateway tunnel. - #[prost(string, tag = "2")] - pub token: ::prost::alloc::string::String, - /// Gateway host for SSH proxy connection. - #[prost(string, tag = "3")] - pub gateway_host: ::prost::alloc::string::String, - /// Gateway port for SSH proxy connection. - #[prost(uint32, tag = "4")] - pub gateway_port: u32, - /// Gateway scheme (http or https). - #[prost(string, tag = "5")] - pub gateway_scheme: ::prost::alloc::string::String, - /// HTTP path for the CONNECT/upgrade endpoint. - #[prost(string, tag = "6")] - pub connect_path: ::prost::alloc::string::String, - /// Optional host key fingerprint. - #[prost(string, tag = "7")] - pub host_key_fingerprint: ::prost::alloc::string::String, -} -/// Revoke SSH session request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct RevokeSshSessionRequest { - /// Session token to revoke. - #[prost(string, tag = "1")] - pub token: ::prost::alloc::string::String, -} -/// Revoke SSH session response. -#[derive(Clone, Copy, PartialEq, ::prost::Message)] -pub struct RevokeSshSessionResponse { - /// True when a session was revoked. - #[prost(bool, tag = "1")] - pub revoked: bool, -} -/// SSH session record stored in persistence. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SshSession { - /// Unique id (token). - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - /// Sandbox id. - #[prost(string, tag = "2")] - pub sandbox_id: ::prost::alloc::string::String, - /// Session token. - #[prost(string, tag = "3")] - pub token: ::prost::alloc::string::String, - /// Creation timestamp in milliseconds since epoch. - #[prost(int64, tag = "4")] - pub created_at_ms: i64, - /// Revoked flag. - #[prost(bool, tag = "5")] - pub revoked: bool, -} -/// Watch sandbox request. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct WatchSandboxRequest { - /// Sandbox id. - #[prost(string, tag = "1")] - pub id: ::prost::alloc::string::String, - /// Stream sandbox status snapshots. - #[prost(bool, tag = "2")] - pub follow_status: bool, - /// Stream openshell-server process logs correlated to this sandbox. - #[prost(bool, tag = "3")] - pub follow_logs: bool, - /// Stream platform events correlated to this sandbox. - #[prost(bool, tag = "4")] - pub follow_events: bool, - /// Replay the last N log lines (best-effort) before following. - #[prost(uint32, tag = "5")] - pub log_tail_lines: u32, - /// Replay the last N platform events (best-effort) before following. - #[prost(uint32, tag = "6")] - pub event_tail: u32, - /// Stop streaming once the sandbox reaches a terminal phase (READY or ERROR). - #[prost(bool, tag = "7")] - pub stop_on_terminal: bool, -} -/// One event in a sandbox watch stream. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxStreamEvent { - #[prost(oneof = "sandbox_stream_event::Payload", tags = "1, 2, 3, 4")] - pub payload: ::core::option::Option, -} -/// Nested message and enum types in `SandboxStreamEvent`. -pub mod sandbox_stream_event { - #[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Payload { - /// Latest sandbox snapshot. - #[prost(message, tag = "1")] - Sandbox(super::super::datamodel::v1::Sandbox), - /// One server log line/event. - #[prost(message, tag = "2")] - Log(super::SandboxLogLine), - /// One platform event. - #[prost(message, tag = "3")] - Event(super::PlatformEvent), - /// Warning from the server (e.g. missed messages due to lag). - #[prost(message, tag = "4")] - Warning(super::SandboxStreamWarning), - } -} -/// OpenShell server process log line correlated to a sandbox. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxLogLine { - #[prost(string, tag = "1")] - pub sandbox_id: ::prost::alloc::string::String, - #[prost(int64, tag = "2")] - pub timestamp_ms: i64, - #[prost(string, tag = "3")] - pub level: ::prost::alloc::string::String, - #[prost(string, tag = "4")] - pub target: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub message: ::prost::alloc::string::String, -} -/// Platform event correlated to a sandbox. -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct PlatformEvent { - /// Event timestamp in milliseconds since epoch. - #[prost(int64, tag = "1")] - pub timestamp_ms: i64, - /// Event source (e.g. "kubernetes", "docker", "process"). - #[prost(string, tag = "2")] - pub source: ::prost::alloc::string::String, - /// Event type/severity (e.g. "Normal", "Warning"). - #[prost(string, tag = "3")] - pub r#type: ::prost::alloc::string::String, - /// Short reason code (e.g. "Started", "Pulled", "Failed"). - #[prost(string, tag = "4")] - pub reason: ::prost::alloc::string::String, - /// Human-readable event message. - #[prost(string, tag = "5")] - pub message: ::prost::alloc::string::String, - /// Optional metadata as key-value pairs. - #[prost(map = "string, string", tag = "6")] - pub metadata: ::std::collections::HashMap< - ::prost::alloc::string::String, - ::prost::alloc::string::String, - >, -} -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct SandboxStreamWarning { - #[prost(string, tag = "1")] - pub message: ::prost::alloc::string::String, -} -/// Service status enum. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] -#[repr(i32)] -pub enum ServiceStatus { - Unspecified = 0, - Healthy = 1, - Degraded = 2, - Unhealthy = 3, -} -impl ServiceStatus { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - Self::Unspecified => "SERVICE_STATUS_UNSPECIFIED", - Self::Healthy => "SERVICE_STATUS_HEALTHY", - Self::Degraded => "SERVICE_STATUS_DEGRADED", - Self::Unhealthy => "SERVICE_STATUS_UNHEALTHY", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "SERVICE_STATUS_UNSPECIFIED" => Some(Self::Unspecified), - "SERVICE_STATUS_HEALTHY" => Some(Self::Healthy), - "SERVICE_STATUS_DEGRADED" => Some(Self::Degraded), - "SERVICE_STATUS_UNHEALTHY" => Some(Self::Unhealthy), - _ => None, - } - } -} -/// Generated client implementations. -pub mod open_shell_client { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - use tonic::codegen::http::Uri; - /// OpenShell service provides agent execution and management capabilities. - #[derive(Debug, Clone)] - pub struct OpenShellClient { - inner: tonic::client::Grpc, - } - impl OpenShellClient { - /// Attempt to create a new client by connecting to a given endpoint. - pub async fn connect(dst: D) -> Result - where - D: TryInto, - D::Error: Into, - { - let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; - Ok(Self::new(conn)) - } - } - impl OpenShellClient - where - T: tonic::client::GrpcService, - T::Error: Into, - T::ResponseBody: Body + std::marker::Send + 'static, - ::Error: Into + std::marker::Send, - { - pub fn new(inner: T) -> Self { - let inner = tonic::client::Grpc::new(inner); - Self { inner } - } - pub fn with_origin(inner: T, origin: Uri) -> Self { - let inner = tonic::client::Grpc::with_origin(inner, origin); - Self { inner } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> OpenShellClient> - where - F: tonic::service::Interceptor, - T::ResponseBody: Default, - T: tonic::codegen::Service< - http::Request, - Response = http::Response< - >::ResponseBody, - >, - >, - , - >>::Error: Into + std::marker::Send + std::marker::Sync, - { - OpenShellClient::new(InterceptedService::new(inner, interceptor)) - } - /// Compress requests with the given encoding. - /// - /// This requires the server to support it otherwise it might respond with an - /// error. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.send_compressed(encoding); - self - } - /// Enable decompressing responses. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.inner = self.inner.accept_compressed(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_decoding_message_size(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.inner = self.inner.max_encoding_message_size(limit); - self - } - /// Check the health of the service. - pub async fn health( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/Health", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "Health")); - self.inner.unary(req, path, codec).await - } - /// Create a new sandbox. - pub async fn create_sandbox( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/CreateSandbox", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "CreateSandbox")); - self.inner.unary(req, path, codec).await - } - /// Fetch a sandbox by id. - pub async fn get_sandbox( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/GetSandbox", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "GetSandbox")); - self.inner.unary(req, path, codec).await - } - /// List sandboxes. - pub async fn list_sandboxes( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/ListSandboxes", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "ListSandboxes")); - self.inner.unary(req, path, codec).await - } - /// Delete a sandbox by id. - pub async fn delete_sandbox( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/DeleteSandbox", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "DeleteSandbox")); - self.inner.unary(req, path, codec).await - } - /// Create a short-lived SSH session for a sandbox. - pub async fn create_ssh_session( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/CreateSshSession", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "CreateSshSession")); - self.inner.unary(req, path, codec).await - } - /// Revoke a previously issued SSH session. - pub async fn revoke_ssh_session( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/RevokeSshSession", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "RevokeSshSession")); - self.inner.unary(req, path, codec).await - } - /// Get sandbox policy by id (called by sandbox entrypoint at startup). - pub async fn get_sandbox_policy( - &mut self, - request: impl tonic::IntoRequest< - super::super::sandbox::v1::GetSandboxPolicyRequest, - >, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/GetSandboxPolicy", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "GetSandboxPolicy")); - self.inner.unary(req, path, codec).await - } - /// Watch a sandbox and stream updates. - /// - /// This stream can include: - /// - Sandbox status snapshots (phase/status) - /// - OpenShell server process logs correlated by sandbox_id - /// - Platform events correlated to the sandbox - pub async fn watch_sandbox( - &mut self, - request: impl tonic::IntoRequest, - ) -> std::result::Result< - tonic::Response>, - tonic::Status, - > { - self.inner - .ready() - .await - .map_err(|e| { - tonic::Status::unknown( - format!("Service was not ready: {}", e.into()), - ) - })?; - let codec = tonic::codec::ProstCodec::default(); - let path = http::uri::PathAndQuery::from_static( - "/openshell.v1.OpenShell/WatchSandbox", - ); - let mut req = request.into_request(); - req.extensions_mut() - .insert(GrpcMethod::new("openshell.v1.OpenShell", "WatchSandbox")); - self.inner.server_streaming(req, path, codec).await - } - } -} -/// Generated server implementations. -pub mod open_shell_server { - #![allow( - unused_variables, - dead_code, - missing_docs, - clippy::wildcard_imports, - clippy::let_unit_value, - )] - use tonic::codegen::*; - /// Generated trait containing gRPC methods that should be implemented for use with OpenShellServer. - #[async_trait] - pub trait OpenShell: std::marker::Send + std::marker::Sync + 'static { - /// Check the health of the service. - async fn health( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - /// Create a new sandbox. - async fn create_sandbox( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - /// Fetch a sandbox by id. - async fn get_sandbox( - &self, - request: tonic::Request, - ) -> std::result::Result, tonic::Status>; - /// List sandboxes. - async fn list_sandboxes( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Delete a sandbox by id. - async fn delete_sandbox( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Create a short-lived SSH session for a sandbox. - async fn create_ssh_session( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Revoke a previously issued SSH session. - async fn revoke_ssh_session( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Get sandbox policy by id (called by sandbox entrypoint at startup). - async fn get_sandbox_policy( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - /// Server streaming response type for the WatchSandbox method. - type WatchSandboxStream: tonic::codegen::tokio_stream::Stream< - Item = std::result::Result, - > - + std::marker::Send - + 'static; - /// Watch a sandbox and stream updates. - /// - /// This stream can include: - /// - Sandbox status snapshots (phase/status) - /// - OpenShell server process logs correlated by sandbox_id - /// - Platform events correlated to the sandbox - async fn watch_sandbox( - &self, - request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - >; - } - /// OpenShell service provides agent execution and management capabilities. - #[derive(Debug)] - pub struct OpenShellServer { - inner: Arc, - accept_compression_encodings: EnabledCompressionEncodings, - send_compression_encodings: EnabledCompressionEncodings, - max_decoding_message_size: Option, - max_encoding_message_size: Option, - } - impl OpenShellServer { - pub fn new(inner: T) -> Self { - Self::from_arc(Arc::new(inner)) - } - pub fn from_arc(inner: Arc) -> Self { - Self { - inner, - accept_compression_encodings: Default::default(), - send_compression_encodings: Default::default(), - max_decoding_message_size: None, - max_encoding_message_size: None, - } - } - pub fn with_interceptor( - inner: T, - interceptor: F, - ) -> InterceptedService - where - F: tonic::service::Interceptor, - { - InterceptedService::new(Self::new(inner), interceptor) - } - /// Enable decompressing requests with the given encoding. - #[must_use] - pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.accept_compression_encodings.enable(encoding); - self - } - /// Compress responses with the given encoding, if the client supports it. - #[must_use] - pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { - self.send_compression_encodings.enable(encoding); - self - } - /// Limits the maximum size of a decoded message. - /// - /// Default: `4MB` - #[must_use] - pub fn max_decoding_message_size(mut self, limit: usize) -> Self { - self.max_decoding_message_size = Some(limit); - self - } - /// Limits the maximum size of an encoded message. - /// - /// Default: `usize::MAX` - #[must_use] - pub fn max_encoding_message_size(mut self, limit: usize) -> Self { - self.max_encoding_message_size = Some(limit); - self - } - } - impl tonic::codegen::Service> for OpenShellServer - where - T: OpenShell, - B: Body + std::marker::Send + 'static, - B::Error: Into + std::marker::Send + 'static, - { - type Response = http::Response; - type Error = std::convert::Infallible; - type Future = BoxFuture; - fn poll_ready( - &mut self, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(())) - } - fn call(&mut self, req: http::Request) -> Self::Future { - match req.uri().path() { - "/openshell.v1.OpenShell/Health" => { - #[allow(non_camel_case_types)] - struct HealthSvc(pub Arc); - impl tonic::server::UnaryService - for HealthSvc { - type Response = super::HealthResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::health(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = HealthSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/CreateSandbox" => { - #[allow(non_camel_case_types)] - struct CreateSandboxSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for CreateSandboxSvc { - type Response = super::SandboxResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::create_sandbox(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CreateSandboxSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/GetSandbox" => { - #[allow(non_camel_case_types)] - struct GetSandboxSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for GetSandboxSvc { - type Response = super::SandboxResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_sandbox(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = GetSandboxSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/ListSandboxes" => { - #[allow(non_camel_case_types)] - struct ListSandboxesSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for ListSandboxesSvc { - type Response = super::ListSandboxesResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::list_sandboxes(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = ListSandboxesSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/DeleteSandbox" => { - #[allow(non_camel_case_types)] - struct DeleteSandboxSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for DeleteSandboxSvc { - type Response = super::DeleteSandboxResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::delete_sandbox(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = DeleteSandboxSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/CreateSshSession" => { - #[allow(non_camel_case_types)] - struct CreateSshSessionSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for CreateSshSessionSvc { - type Response = super::CreateSshSessionResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::create_ssh_session(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = CreateSshSessionSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/RevokeSshSession" => { - #[allow(non_camel_case_types)] - struct RevokeSshSessionSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService - for RevokeSshSessionSvc { - type Response = super::RevokeSshSessionResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::revoke_ssh_session(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = RevokeSshSessionSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/GetSandboxPolicy" => { - #[allow(non_camel_case_types)] - struct GetSandboxPolicySvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::UnaryService< - super::super::sandbox::v1::GetSandboxPolicyRequest, - > for GetSandboxPolicySvc { - type Response = super::super::sandbox::v1::GetSandboxPolicyResponse; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request< - super::super::sandbox::v1::GetSandboxPolicyRequest, - >, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::get_sandbox_policy(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = GetSandboxPolicySvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.unary(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - "/openshell.v1.OpenShell/WatchSandbox" => { - #[allow(non_camel_case_types)] - struct WatchSandboxSvc(pub Arc); - impl< - T: OpenShell, - > tonic::server::ServerStreamingService - for WatchSandboxSvc { - type Response = super::SandboxStreamEvent; - type ResponseStream = T::WatchSandboxStream; - type Future = BoxFuture< - tonic::Response, - tonic::Status, - >; - fn call( - &mut self, - request: tonic::Request, - ) -> Self::Future { - let inner = Arc::clone(&self.0); - let fut = async move { - ::watch_sandbox(&inner, request).await - }; - Box::pin(fut) - } - } - let accept_compression_encodings = self.accept_compression_encodings; - let send_compression_encodings = self.send_compression_encodings; - let max_decoding_message_size = self.max_decoding_message_size; - let max_encoding_message_size = self.max_encoding_message_size; - let inner = self.inner.clone(); - let fut = async move { - let method = WatchSandboxSvc(inner); - let codec = tonic::codec::ProstCodec::default(); - let mut grpc = tonic::server::Grpc::new(codec) - .apply_compression_config( - accept_compression_encodings, - send_compression_encodings, - ) - .apply_max_message_size_config( - max_decoding_message_size, - max_encoding_message_size, - ); - let res = grpc.server_streaming(method, req).await; - Ok(res) - }; - Box::pin(fut) - } - _ => { - Box::pin(async move { - let mut response = http::Response::new(empty_body()); - let headers = response.headers_mut(); - headers - .insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers - .insert( - http::header::CONTENT_TYPE, - tonic::metadata::GRPC_CONTENT_TYPE, - ); - Ok(response) - }) - } - } - } - } - impl Clone for OpenShellServer { - fn clone(&self) -> Self { - let inner = self.inner.clone(); - Self { - inner, - accept_compression_encodings: self.accept_compression_encodings, - send_compression_encodings: self.send_compression_encodings, - max_decoding_message_size: self.max_decoding_message_size, - max_encoding_message_size: self.max_encoding_message_size, - } - } - } - /// Generated gRPC service name - pub const SERVICE_NAME: &str = "openshell.v1.OpenShell"; - impl tonic::server::NamedService for OpenShellServer { - const NAME: &'static str = SERVICE_NAME; - } -} diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 311bb4e86..f26136c6b 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -13,7 +13,7 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core" } serde = { workspace = true } -serde_yaml = { workspace = true } +serde_yml = { workspace = true } miette = { workspace = true } [lints] diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 7adb4dfda..9cf543bdf 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -82,11 +82,12 @@ struct NetworkEndpointDef { #[serde(default, skip_serializing_if = "String::is_empty")] host: String, /// Single port (backwards compat). Mutually exclusive with `ports`. + /// Uses `u16` to reject invalid values >65535 at parse time. #[serde(default, skip_serializing_if = "is_zero")] - port: u32, + port: u16, /// Multiple ports. When non-empty, this endpoint covers all listed ports. #[serde(default, skip_serializing_if = "Vec::is_empty")] - ports: Vec, + ports: Vec, #[serde(default, skip_serializing_if = "String::is_empty")] protocol: String, #[serde(default, skip_serializing_if = "String::is_empty")] @@ -101,7 +102,7 @@ struct NetworkEndpointDef { allowed_ips: Vec, } -fn is_zero(v: &u32) -> bool { +fn is_zero(v: &u16) -> bool { *v == 0 } @@ -169,10 +170,10 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { .map(|e| { // Normalize port/ports: ports takes precedence, else // single port is promoted to ports array. - let normalized_ports = if !e.ports.is_empty() { - e.ports + let normalized_ports: Vec = if !e.ports.is_empty() { + e.ports.into_iter().map(u32::from).collect() } else if e.port > 0 { - vec![e.port] + vec![u32::from(e.port)] } else { vec![] }; @@ -285,10 +286,12 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { .map(|e| { // Use compact form: if ports has exactly 1 element, // emit port (scalar). If >1, emit ports (array). + // Proto uses u32; YAML uses u16. Clamp at boundary. + let clamp = |v: u32| -> u16 { v.min(65535) as u16 }; let (port, ports) = if e.ports.len() > 1 { - (0, e.ports.clone()) + (0, e.ports.iter().map(|&p| clamp(p)).collect()) } else { - (e.ports.first().copied().unwrap_or(e.port), vec![]) + (clamp(e.ports.first().copied().unwrap_or(e.port)), vec![]) }; NetworkEndpointDef { host: e.host.clone(), @@ -358,7 +361,7 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { /// Parse a sandbox policy from a YAML string. pub fn parse_sandbox_policy(yaml: &str) -> Result { - let raw: PolicyFile = serde_yaml::from_str(yaml) + let raw: PolicyFile = serde_yml::from_str(yaml) .into_diagnostic() .wrap_err("failed to parse sandbox policy YAML")?; Ok(to_proto(raw)) @@ -371,7 +374,7 @@ pub fn parse_sandbox_policy(yaml: &str) -> Result { /// and is round-trippable through `parse_sandbox_policy`. pub fn serialize_sandbox_policy(policy: &SandboxPolicy) -> Result { let yaml_repr = from_proto(policy); - serde_yaml::to_string(&yaml_repr) + serde_yml::to_string(&yaml_repr) .into_diagnostic() .wrap_err("failed to serialize policy to YAML") } @@ -1207,4 +1210,20 @@ network_policies: proto2.network_policies["test"].endpoints[0].host ); } + + #[test] + fn rejects_port_above_65535() { + let yaml = r#" +version: 1 +network_policies: + test: + endpoints: + - host: example.com + port: 70000 +"#; + assert!( + parse_sandbox_policy(yaml).is_err(), + "port >65535 should fail to parse" + ); + } } diff --git a/crates/openshell-router/Cargo.toml b/crates/openshell-router/Cargo.toml index dc8e9c924..e4c3d5ea7 100644 --- a/crates/openshell-router/Cargo.toml +++ b/crates/openshell-router/Cargo.toml @@ -19,7 +19,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } -serde_yaml = { workspace = true } +serde_yml = { workspace = true } uuid = { workspace = true } [dev-dependencies] diff --git a/crates/openshell-router/src/config.rs b/crates/openshell-router/src/config.rs index 52c22da9f..b531e091d 100644 --- a/crates/openshell-router/src/config.rs +++ b/crates/openshell-router/src/config.rs @@ -75,7 +75,7 @@ impl RouterConfig { path.display() )) })?; - let config: Self = serde_yaml::from_str(&content).map_err(|e| { + let config: Self = serde_yml::from_str(&content).map_err(|e| { RouterError::Internal(format!( "failed to parse router config {}: {e}", path.display() diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 68e696e95..e8e7e2c97 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -60,7 +60,7 @@ ipnet = "2" # Serialization serde_json = { workspace = true } -serde_yaml = { workspace = true } +serde_yml = { workspace = true } # Logging tracing = { workspace = true } diff --git a/crates/openshell-sandbox/src/opa.rs b/crates/openshell-sandbox/src/opa.rs index f1df12ff4..f1c0ad293 100644 --- a/crates/openshell-sandbox/src/opa.rs +++ b/crates/openshell-sandbox/src/opa.rs @@ -511,7 +511,7 @@ fn parse_process_policy(val: ®orus::Value) -> ProcessPolicy { /// Preprocess YAML policy data: parse, normalize, validate, expand access presets, return JSON. fn preprocess_yaml_data(yaml_str: &str) -> Result { - let mut data: serde_json::Value = serde_yaml::from_str(yaml_str) + let mut data: serde_json::Value = serde_yml::from_str(yaml_str) .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; // Normalize port → ports for all endpoints so Rego always sees "ports" array. diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index a7df76e2f..9e87450d4 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -23,6 +23,12 @@ use tracing::{debug, info, warn}; const MAX_HEADER_BYTES: usize = 8192; const INFERENCE_LOCAL_HOST: &str = "inference.local"; +/// Maximum total bytes for a streaming inference response body (32 MiB). +const MAX_STREAMING_BODY: usize = 32 * 1024 * 1024; + +/// Idle timeout per chunk when relaying streaming inference responses. +const CHUNK_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + /// Result of a proxy CONNECT policy decision. struct ConnectDecision { action: NetworkAction, @@ -1045,18 +1051,35 @@ async fn route_inference_request( let header_bytes = format_http_response_header(resp.status, &resp_headers); write_all(tls_client, &header_bytes).await?; - // Stream body chunks as they arrive from the upstream. + // Stream body chunks with byte cap and idle timeout. + let mut total_bytes: usize = 0; loop { - match resp.next_chunk().await { - Ok(Some(chunk)) => { + match tokio::time::timeout(CHUNK_IDLE_TIMEOUT, resp.next_chunk()).await { + Ok(Ok(Some(chunk))) => { + total_bytes += chunk.len(); + if total_bytes > MAX_STREAMING_BODY { + warn!( + total_bytes = total_bytes, + limit = MAX_STREAMING_BODY, + "streaming response exceeded byte limit, truncating" + ); + break; + } let encoded = format_chunk(&chunk); write_all(tls_client, &encoded).await?; } - Ok(None) => break, - Err(e) => { + Ok(Ok(None)) => break, + Ok(Err(e)) => { warn!(error = %e, "error reading upstream response chunk"); break; } + Err(_) => { + warn!( + idle_timeout_secs = CHUNK_IDLE_TIMEOUT.as_secs(), + "streaming response chunk idle timeout, closing" + ); + break; + } } } diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index 6c9d8307b..e23447498 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -2,6 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 //! Seccomp syscall filtering. +//! +//! The filter uses a default-allow policy with targeted blocks: +//! +//! 1. **Socket domain blocks** -- prevent raw/kernel sockets that bypass the proxy +//! 2. **Unconditional syscall blocks** -- block syscalls that enable sandbox escape +//! (fileless exec, ptrace, BPF, cross-process memory access, io_uring, mount) +//! 3. **Conditional syscall blocks** -- block dangerous flag combinations on otherwise +//! needed syscalls (execveat+AT_EMPTY_PATH, unshare+CLONE_NEWUSER, +//! seccomp+SET_MODE_FILTER) use crate::policy::{NetworkMode, SandboxPolicy}; use miette::{IntoDiagnostic, Result}; @@ -13,6 +22,9 @@ use std::collections::BTreeMap; use std::convert::TryInto; use tracing::debug; +/// Value of `SECCOMP_SET_MODE_FILTER` (linux/seccomp.h). +const SECCOMP_SET_MODE_FILTER: u64 = 1; + pub fn apply(policy: &SandboxPolicy) -> Result<()> { if matches!(policy.network.mode, NetworkMode::Allow) { return Ok(()); @@ -37,6 +49,7 @@ pub fn apply(policy: &SandboxPolicy) -> Result<()> { fn build_filter(allow_inet: bool) -> Result { let mut rules: BTreeMap> = BTreeMap::new(); + // --- Socket domain blocks --- let mut blocked_domains = vec![libc::AF_PACKET, libc::AF_BLUETOOTH, libc::AF_VSOCK]; if !allow_inet { blocked_domains.push(libc::AF_INET); @@ -49,6 +62,51 @@ fn build_filter(allow_inet: bool) -> Result { add_socket_domain_rule(&mut rules, domain)?; } + // --- Unconditional syscall blocks --- + // These syscalls are blocked entirely (empty rule vec = unconditional EPERM). + + // Fileless binary execution via memfd bypasses Landlock filesystem restrictions. + rules.entry(libc::SYS_memfd_create).or_default(); + // Cross-process memory inspection and code injection. + rules.entry(libc::SYS_ptrace).or_default(); + // Kernel BPF program loading. + rules.entry(libc::SYS_bpf).or_default(); + // Cross-process memory read. + rules.entry(libc::SYS_process_vm_readv).or_default(); + // Async I/O subsystem with extensive CVE history. + rules.entry(libc::SYS_io_uring_setup).or_default(); + // Filesystem mount could subvert Landlock or overlay writable paths. + rules.entry(libc::SYS_mount).or_default(); + + // --- Conditional syscall blocks --- + + // execveat with AT_EMPTY_PATH enables fileless execution from an anonymous fd. + add_masked_arg_rule( + &mut rules, + libc::SYS_execveat, + 4, // flags argument + libc::AT_EMPTY_PATH as u64, + )?; + + // unshare with CLONE_NEWUSER allows creating user namespaces to escalate privileges. + add_masked_arg_rule( + &mut rules, + libc::SYS_unshare, + 0, // flags argument + libc::CLONE_NEWUSER as u64, + )?; + + // seccomp(SECCOMP_SET_MODE_FILTER) would let sandboxed code replace the active filter. + let condition = SeccompCondition::new( + 0, // operation argument + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + SECCOMP_SET_MODE_FILTER, + ) + .into_diagnostic()?; + let rule = SeccompRule::new(vec![condition]).into_diagnostic()?; + rules.entry(libc::SYS_seccomp).or_default().push(rule); + let arch = std::env::consts::ARCH .try_into() .map_err(|_| miette::miette!("Unsupported architecture for seccomp"))?; @@ -74,3 +132,127 @@ fn add_socket_domain_rule(rules: &mut BTreeMap>, domain: i rules.entry(libc::SYS_socket).or_default().push(rule); Ok(()) } + +/// Block a syscall when a specific bit pattern is set in an argument. +/// +/// Uses `MaskedEq` to check `(arg & flag_bit) == flag_bit`, which triggers +/// EPERM when the flag is present regardless of other bits in the argument. +fn add_masked_arg_rule( + rules: &mut BTreeMap>, + syscall: i64, + arg_index: u8, + flag_bit: u64, +) -> Result<()> { + let condition = SeccompCondition::new( + arg_index, + SeccompCmpArgLen::Dword, + SeccompCmpOp::MaskedEq(flag_bit), + flag_bit, + ) + .into_diagnostic()?; + let rule = SeccompRule::new(vec![condition]).into_diagnostic()?; + rules.entry(syscall).or_default().push(rule); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_filter_proxy_mode_compiles() { + let filter = build_filter(true); + assert!(filter.is_ok(), "build_filter(true) should succeed"); + } + + #[test] + fn build_filter_block_mode_compiles() { + let filter = build_filter(false); + assert!(filter.is_ok(), "build_filter(false) should succeed"); + } + + #[test] + fn add_masked_arg_rule_creates_entry() { + let mut rules: BTreeMap> = BTreeMap::new(); + let result = add_masked_arg_rule(&mut rules, libc::SYS_execveat, 4, 0x1000); + assert!(result.is_ok()); + assert!( + rules.contains_key(&libc::SYS_execveat), + "should have an entry for SYS_execveat" + ); + assert_eq!( + rules[&libc::SYS_execveat].len(), + 1, + "should have exactly one rule" + ); + } + + #[test] + fn unconditional_blocks_present_in_filter() { + let mut rules: BTreeMap> = BTreeMap::new(); + + // Simulate what build_filter does for unconditional blocks + rules.entry(libc::SYS_memfd_create).or_default(); + rules.entry(libc::SYS_ptrace).or_default(); + rules.entry(libc::SYS_bpf).or_default(); + rules.entry(libc::SYS_process_vm_readv).or_default(); + rules.entry(libc::SYS_io_uring_setup).or_default(); + rules.entry(libc::SYS_mount).or_default(); + + // Unconditional blocks have an empty Vec (no conditions = always match) + for syscall in [ + libc::SYS_memfd_create, + libc::SYS_ptrace, + libc::SYS_bpf, + libc::SYS_process_vm_readv, + libc::SYS_io_uring_setup, + libc::SYS_mount, + ] { + assert!( + rules.contains_key(&syscall), + "syscall {syscall} should be in the rules map" + ); + assert!( + rules[&syscall].is_empty(), + "syscall {syscall} should have empty rules (unconditional block)" + ); + } + } + + #[test] + fn conditional_blocks_have_rules() { + // Build a real filter and verify the conditional syscalls have rule entries + // (non-empty Vec means conditional match) + let mut rules: BTreeMap> = BTreeMap::new(); + + add_masked_arg_rule( + &mut rules, + libc::SYS_execveat, + 4, + libc::AT_EMPTY_PATH as u64, + ) + .unwrap(); + add_masked_arg_rule(&mut rules, libc::SYS_unshare, 0, libc::CLONE_NEWUSER as u64).unwrap(); + + let condition = SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + SECCOMP_SET_MODE_FILTER, + ) + .unwrap(); + let rule = SeccompRule::new(vec![condition]).unwrap(); + rules.entry(libc::SYS_seccomp).or_default().push(rule); + + for syscall in [libc::SYS_execveat, libc::SYS_unshare, libc::SYS_seccomp] { + assert!( + rules.contains_key(&syscall), + "syscall {syscall} should be in the rules map" + ); + assert!( + !rules[&syscall].is_empty(), + "syscall {syscall} should have conditional rules" + ); + } + } +} diff --git a/crates/openshell-server/src/auth.rs b/crates/openshell-server/src/auth.rs index 5a3229ffa..b896d062c 100644 --- a/crates/openshell-server/src/auth.rs +++ b/crates/openshell-server/src/auth.rs @@ -22,11 +22,28 @@ use axum::{ response::{Html, IntoResponse}, routing::get, }; +use http::header; use serde::Deserialize; use std::sync::Arc; use crate::ServerState; +/// Validate that a confirmation code matches the CLI-generated format. +/// +/// Codes are 3 alphanumeric characters, a dash, then 4 alphanumeric characters +/// (e.g., "AB7-X9KM"). The CLI generates these from the charset `[A-Z2-9]`. +fn is_valid_code(code: &str) -> bool { + let bytes = code.as_bytes(); + bytes.len() == 8 + && bytes[3] == b'-' + && bytes[..3] + .iter() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) + && bytes[4..] + .iter() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) +} + #[derive(Deserialize)] struct ConnectParams { callback_port: u16, @@ -54,6 +71,15 @@ async fn auth_connect( Query(params): Query, headers: HeaderMap, ) -> impl IntoResponse { + // Reject codes that don't match the CLI-generated format to prevent + // reflected XSS via crafted URLs. + if !is_valid_code(¶ms.code) { + return Html( + "

Invalid confirmation code format.

".to_string(), + ) + .into_response(); + } + let cf_token = headers .get("cookie") .and_then(|v| v.to_str().ok()) @@ -68,14 +94,34 @@ async fn auth_connect( .and_then(|v| v.to_str().ok()) .map_or_else(|| state.config.bind_address.to_string(), String::from); + let safe_gateway = html_escape(&gateway_display); + match cf_token { - Some(token) => Html(render_connect_page( - &gateway_display, - params.callback_port, - &token, - ¶ms.code, - )), - None => Html(render_waiting_page(params.callback_port, ¶ms.code)), + Some(token) => { + let nonce = uuid::Uuid::new_v4().to_string(); + let csp = format!( + "default-src 'none'; script-src 'nonce-{nonce}'; style-src 'unsafe-inline'; connect-src http://127.0.0.1:*" + ); + ( + [(header::CONTENT_SECURITY_POLICY, csp)], + Html(render_connect_page( + &safe_gateway, + params.callback_port, + &token, + ¶ms.code, + &nonce, + )), + ) + .into_response() + } + None => { + let csp = "default-src 'none'; style-src 'unsafe-inline'".to_string(); + ( + [(header::CONTENT_SECURITY_POLICY, csp)], + Html(render_waiting_page(params.callback_port, ¶ms.code)), + ) + .into_response() + } } } @@ -104,22 +150,27 @@ fn render_connect_page( callback_port: u16, cf_token: &str, code: &str, + nonce: &str, ) -> String { - // Escape the token for safe embedding in a JS string literal. - let escaped_token = cf_token - .replace('\\', "\\\\") - .replace('\'', "\\'") - .replace('"', "\\\"") - .replace('<', "\\x3c") - .replace('>', "\\x3e"); + // Use JSON serialization for JS-safe string embedding — handles all + // edge cases including \n, \r, U+2028, U+2029 that break JS string + // literals. serde_json::to_string produces a quoted JSON string + // (e.g., "value") which is a valid JS string literal. + // + // We additionally escape < and > to \u003c / \u003e because while + // they're valid in JSON, they're dangerous inside an HTML before the JS parser runs). + let json_token = serde_json::to_string(cf_token) + .unwrap_or_else(|_| "\"\"".to_string()) + .replace('<', "\\u003c") + .replace('>', "\\u003e"); + let json_code = serde_json::to_string(code) + .unwrap_or_else(|_| "\"\"".to_string()) + .replace('<', "\\u003c") + .replace('>', "\\u003e"); - // Escape the code the same way (it's alphanumeric + dash, but be safe). - let escaped_code = code - .replace('\\', "\\\\") - .replace('\'', "\\'") - .replace('"', "\\\"") - .replace('<', "\\x3c") - .replace('>', "\\x3e"); + // HTML-safe version of the code for display in the page body. + let html_code = html_escape(code); let version = openshell_core::VERSION; @@ -250,7 +301,7 @@ fn render_connect_page(
Connect to Gateway
Confirmation Code
-
{escaped_code}
+
{html_code}
Verify this matches the code shown in your terminal
@@ -271,9 +322,9 @@ fn render_connect_page(
- ", "ABC-1234"); - // < and > should be escaped + let html = render_connect_page( + "gw", + 1234, + "token", + "ABC-1234", + "nonce", + ); + // < and > should be escaped via JSON encoding (\u003c) assert!(!html.contains("