diff --git a/Cargo.lock b/Cargo.lock index fe98751..0aa0bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "sandbox-aegis" -version = "0.1.1" +version = "0.1.5" dependencies = [ "chrono", "sandbox-core", @@ -2080,22 +2080,29 @@ dependencies = [ [[package]] name = "sandbox-cli" -version = "0.1.1" +version = "0.1.5" dependencies = [ "anyhow", + "base64", "clap", + "futures-util", + "http", + "reqwest", + "rustls", "sandbox-client", "sandbox-core", "secrecy", "serde", "serde_json", "tokio", + "tokio-tungstenite", "url", + "uuid", ] [[package]] name = "sandbox-client" -version = "0.1.1" +version = "0.1.5" dependencies = [ "reqwest", "sandbox-core", @@ -2108,7 +2115,7 @@ dependencies = [ [[package]] name = "sandbox-core" -version = "0.1.1" +version = "0.1.5" dependencies = [ "chrono", "config", @@ -2122,7 +2129,7 @@ dependencies = [ [[package]] name = "sandbox-events" -version = "0.1.1" +version = "0.1.5" dependencies = [ "async-nats", "async-trait", @@ -2135,7 +2142,7 @@ dependencies = [ [[package]] name = "sandbox-mcp" -version = "0.1.1" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -2150,7 +2157,7 @@ dependencies = [ [[package]] name = "sandbox-runtime" -version = "0.1.1" +version = "0.1.5" dependencies = [ "async-trait", "sandbox-core", @@ -2164,7 +2171,7 @@ dependencies = [ [[package]] name = "sandbox-storage" -version = "0.1.1" +version = "0.1.5" dependencies = [ "async-trait", "chrono", @@ -2183,10 +2190,11 @@ dependencies = [ [[package]] name = "sandboxd" -version = "0.1.1" +version = "0.1.5" dependencies = [ "anyhow", "axum", + "base64", "chrono", "clap", "futures-util", @@ -2200,6 +2208,7 @@ dependencies = [ "serde", "serde_json", "subtle", + "tempfile", "tokio", "tokio-util", "tower-http 0.7.0", @@ -2732,7 +2741,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -2971,6 +2984,8 @@ dependencies = [ "httparse", "log", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 51c1f0a..e332e35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.5" edition = "2024" rust-version = "1.97.1" authors = ["Sandbox contributors"] @@ -37,6 +37,7 @@ http = "1.4.2" openssl-sys = { version = "0.9.117", features = ["vendored"] } pq-sys = { version = "0.7.5", features = ["bundled"] } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } +rustls = { version = "0.23.42", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } secrecy = { version = "0.10.3", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" @@ -45,6 +46,7 @@ subtle = "2.6.1" tempfile = "3.27.0" thiserror = "2.0.18" tokio = { version = "1.53.0", features = ["full"] } +tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-native-roots"] } tokio-util = "0.7.18" tower-http = { version = "0.7.0", features = ["catch-panic", "cors", "limit", "request-id", "timeout", "trace"] } tracing = "0.1.44" @@ -53,12 +55,12 @@ url = { version = "2.5.8", features = ["serde"] } uuid = { version = "1.24.0", features = ["serde", "v7"] } zeroize = { version = "1.8.2", features = ["derive"] } -sandbox-aegis = { version = "0.1.1", path = "crates/aegis" } -sandbox-client = { version = "0.1.1", path = "crates/client" } -sandbox-core = { version = "0.1.1", path = "crates/core" } -sandbox-events = { version = "0.1.1", path = "crates/events" } -sandbox-runtime = { version = "0.1.1", path = "crates/runtime" } -sandbox-storage = { version = "0.1.1", path = "crates/storage" } +sandbox-aegis = { version = "0.1.5", path = "crates/aegis" } +sandbox-client = { version = "0.1.5", path = "crates/client" } +sandbox-core = { version = "0.1.5", path = "crates/core" } +sandbox-events = { version = "0.1.5", path = "crates/events" } +sandbox-runtime = { version = "0.1.5", path = "crates/runtime" } +sandbox-storage = { version = "0.1.5", path = "crates/storage" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 649b1e3..413879a 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,14 @@ sandbox create --tenant platform --image ubuntu:24.04 \ --network restricted --untrusted-repo --generated-code sandbox exec 019f... -- cargo test --workspace +sandbox http 3000 sandbox tunnel create 019f... --port 3000 sandbox agent run codex --tenant platform sandbox delete 019f... --wait ``` +`sandbox http PORT` shares a service running on your current machine at a temporary `https://local-….tunnel.yshubham.com` URL through the hosted Sandbox relay. No third-party quick-tunnel domain or helper binary is involved. HTTP and WebSocket traffic—including Vite HMR—is carried over one outbound WebSocket and rewritten to the loopback origin, so development servers never need to allow a random public `Host`. Keep the command running and press Ctrl-C to revoke the exact-host route. Self-hosters can set `SANDBOX_HTTP_RELAY`; services inside a managed sandbox continue to use `sandbox tunnel create SANDBOX_ID --port PORT`. + ## What is already real | Capability | Status | @@ -80,6 +83,7 @@ sandbox delete 019f... --wait | CLI lifecycle, agent profiles, JSON output, bounded exec | Implemented | | MCP 2025-11-25 stdio server with structured tool results | Implemented | | Wildcard HTTP/WebSocket tunnels with per-sandbox edge networks | Implemented; custom HTTPS domains, direct edge, and Cloudflare ingress documented | +| Local HTTP/WebSocket sharing on the deployment wildcard | Implemented; ephemeral exact-host routes, bounded sessions/bodies, automatic revocation | | Codex, Claude Code, OpenCode, Pi image builder | Implemented with pinned versions | | Aider and Goose profiles | Implemented using their official images | | OIDC/SAML, tenant RBAC, secret broker, interactive PTY, raw TCP tunnels | Design boundary; not implemented in v0.1 | diff --git a/cmd/sandbox/Cargo.toml b/cmd/sandbox/Cargo.toml index f784c07..0aa4ed9 100644 --- a/cmd/sandbox/Cargo.toml +++ b/cmd/sandbox/Cargo.toml @@ -14,14 +14,21 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +base64.workspace = true clap.workspace = true +futures-util.workspace = true +http.workspace = true +reqwest.workspace = true +rustls.workspace = true sandbox-client.workspace = true sandbox-core.workspace = true secrecy.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true +tokio-tungstenite.workspace = true url.workspace = true +uuid.workspace = true [lints] workspace = true diff --git a/cmd/sandbox/src/local_http.rs b/cmd/sandbox/src/local_http.rs new file mode 100644 index 0000000..f9e271c --- /dev/null +++ b/cmd/sandbox/src/local_http.rs @@ -0,0 +1,479 @@ +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context, Result, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use futures_util::{SinkExt, StreamExt}; +use http::{HeaderName, HeaderValue, Method}; +use sandbox_core::api::{LocalRelayClientMessage, LocalRelayServerMessage, RelayWebSocketFrame}; +use tokio::{ + net::TcpStream, + sync::{Mutex, mpsc}, +}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{ + Message, + client::IntoClientRequest, + protocol::{CloseFrame, frame::coding::CloseCode}, + }, +}; +use url::Url; +use uuid::Uuid; + +const MAX_RELAY_BODY_BYTES: usize = 64 * 1_048_576; + +pub(crate) async fn run( + port: u16, + mut relay_url: Url, + subdomain: Option, + token: Option<&str>, + json: bool, +) -> Result<()> { + let local_address = ensure_local_listener(port).await?; + relay_url + .set_scheme(match relay_url.scheme() { + "https" => "wss", + "http" => "ws", + "wss" => "wss", + "ws" => "ws", + _ => bail!("the local relay URL must use HTTPS or HTTP"), + }) + .map_err(|()| anyhow::anyhow!("invalid local relay URL"))?; + relay_url.set_path("/v1/local-tunnels/connect"); + relay_url.set_query(None); + if let Some(subdomain) = &subdomain { + relay_url + .query_pairs_mut() + .append_pair("subdomain", subdomain); + } + + let mut request = relay_url + .as_str() + .into_client_request() + .context("build local relay request")?; + request.headers_mut().insert( + http::header::USER_AGENT, + HeaderValue::from_static(concat!("sandbox/", env!("CARGO_PKG_VERSION"))), + ); + if let Some(token) = token { + request.headers_mut().insert( + http::header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")) + .context("build relay authorization header")?, + ); + } + let (socket, _) = connect_async(request) + .await + .with_context(|| format!("connect to the Sandbox relay at {relay_url}"))?; + let (mut socket_tx, mut socket_rx) = socket.split(); + let (outbound_tx, mut outbound_rx) = mpsc::channel::(256); + let writer = tokio::spawn(async move { + let mut heartbeat = tokio::time::interval(Duration::from_secs(25)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + message = outbound_rx.recv() => { + let Some(message) = message else { return }; + if socket_tx.send(message).await.is_err() { return; } + } + _ = heartbeat.tick() => { + if socket_tx.send(Message::Ping(Vec::new().into())).await.is_err() { return; } + } + } + } + }); + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .context("build local HTTP client")?; + let websocket_inputs: Arc>>> = + Arc::default(); + let mut ready = false; + + loop { + tokio::select! { + signal = tokio::signal::ctrl_c(), if ready => { + signal.context("listen for Ctrl-C")?; + break; + } + message = socket_rx.next() => { + let Some(message) = message else { + if ready { bail!("the Sandbox relay disconnected unexpectedly"); } + bail!("the Sandbox relay closed before issuing a public URL"); + }; + match message.context("read from the Sandbox relay")? { + Message::Text(text) => { + let message = serde_json::from_str::(&text) + .context("decode Sandbox relay message")?; + match message { + LocalRelayServerMessage::Ready { public_url, expires_at, .. } => { + ready = true; + if json { + println!("{}", serde_json::to_string(&serde_json::json!({ + "local_url": format!("http://{local_address}"), + "provider": "sandbox", + "public_url": public_url, + "expires_at": expires_at, + }))?); + } else { + println!("{public_url}"); + eprintln!("Public URL: anyone with this address can access your local service."); + eprintln!("Press Ctrl-C to stop sharing."); + } + } + LocalRelayServerMessage::HttpRequest { request_id, method, uri, headers, body_base64 } => { + let outbound = outbound_tx.clone(); + let http = http.clone(); + tokio::spawn(async move { + let response = relay_http_request(http, local_address, method, uri, headers, body_base64).await; + let message = match response { + Ok((status, headers, body_base64)) => LocalRelayClientMessage::HttpResponse { + request_id, + status, + headers, + body_base64, + }, + Err(error) => LocalRelayClientMessage::HttpResponse { + request_id, + status: 502, + headers: vec![("content-type".into(), "text/plain; charset=utf-8".into())], + body_base64: BASE64.encode(format!("local service error: {error}")), + }, + }; + send_protocol_message(&outbound, &message).await; + }); + } + LocalRelayServerMessage::WebSocketOpen { request_id, uri, headers } => { + let outbound = outbound_tx.clone(); + let inputs = websocket_inputs.clone(); + let (input_tx, input_rx) = mpsc::channel(256); + inputs.lock().await.insert(request_id, input_tx); + tokio::spawn(async move { + relay_local_websocket(local_address, request_id, uri, headers, input_rx, outbound.clone()).await; + inputs.lock().await.remove(&request_id); + }); + } + LocalRelayServerMessage::WebSocketFrame { request_id, frame } => { + let input = websocket_inputs.lock().await.get(&request_id).cloned(); + if let Some(input) = input { + let _ = input.send(frame).await; + } + } + LocalRelayServerMessage::WebSocketClose { request_id } => { + websocket_inputs.lock().await.remove(&request_id); + } + LocalRelayServerMessage::Error { message } => bail!("Sandbox relay: {message}"), + } + } + Message::Ping(data) => { + let _ = outbound_tx.send(Message::Pong(data)).await; + } + Message::Close(frame) => { + let reason = frame.map(|frame| frame.reason.to_string()).unwrap_or_default(); + if reason.is_empty() { bail!("the Sandbox relay closed the tunnel"); } + bail!("the Sandbox relay closed the tunnel: {reason}"); + } + Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {} + } + } + } + } + + writer.abort(); + websocket_inputs.lock().await.clear(); + Ok(()) +} + +async fn relay_http_request( + http: reqwest::Client, + local_address: SocketAddr, + method: String, + uri: String, + headers: Vec<(String, String)>, + body_base64: String, +) -> Result<(u16, Vec<(String, String)>, String)> { + let url = local_url(local_address, &uri, "http")?; + let method = Method::from_bytes(method.as_bytes()).context("invalid HTTP method")?; + let body = BASE64.decode(body_base64).context("invalid request body")?; + if body.len() > MAX_RELAY_BODY_BYTES { + bail!("request body exceeded 64 MiB"); + } + let mut request = http.request(method, url).body(body); + for (name, value) in headers { + let Ok(name) = HeaderName::try_from(name) else { + continue; + }; + if skip_local_header(&name) { + continue; + } + let Ok(value) = HeaderValue::try_from(value) else { + continue; + }; + request = request.header(name, value); + } + let mut response = request.send().await.context("request local service")?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .filter(|(name, _)| !is_hop_by_hop(name)) + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.to_string(), value.to_owned())) + }) + .collect(); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .context("read local service response")? + { + if body.len().saturating_add(chunk.len()) > MAX_RELAY_BODY_BYTES { + bail!("local service response exceeded 64 MiB"); + } + body.extend_from_slice(&chunk); + } + Ok((status, headers, BASE64.encode(body))) +} + +async fn relay_local_websocket( + local_address: SocketAddr, + request_id: Uuid, + uri: String, + headers: Vec<(String, String)>, + mut input: mpsc::Receiver, + outbound: mpsc::Sender, +) { + let result = async { + let url = local_url(local_address, &uri, "ws")?; + let mut request = url + .as_str() + .into_client_request() + .context("build local WebSocket request")?; + for (name, value) in headers { + let Ok(name) = HeaderName::try_from(name) else { continue }; + if skip_local_websocket_header(&name) { continue; } + let Ok(value) = HeaderValue::try_from(value) else { continue }; + request.headers_mut().append(name, value); + } + let (socket, _) = connect_async(request) + .await + .context("connect to local WebSocket")?; + send_protocol_message( + &outbound, + &LocalRelayClientMessage::WebSocketReady { request_id, error: None }, + ) + .await; + let (mut local_tx, mut local_rx) = socket.split(); + loop { + tokio::select! { + frame = input.recv() => { + let Some(frame) = frame else { break }; + let closing = matches!(frame, RelayWebSocketFrame::Close { .. }); + local_tx.send(relay_frame_to_tungstenite(frame)).await.context("write local WebSocket")?; + if closing { break; } + } + message = local_rx.next() => { + let Some(message) = message else { break }; + let frame = tungstenite_frame_to_relay(message.context("read local WebSocket")?); + let closing = matches!(frame, RelayWebSocketFrame::Close { .. }); + send_protocol_message( + &outbound, + &LocalRelayClientMessage::WebSocketFrame { request_id, frame }, + ).await; + if closing { break; } + } + } + } + Result::<()>::Ok(()) + } + .await; + + if let Err(error) = result { + send_protocol_message( + &outbound, + &LocalRelayClientMessage::WebSocketReady { + request_id, + error: Some(error.to_string()), + }, + ) + .await; + } + send_protocol_message( + &outbound, + &LocalRelayClientMessage::WebSocketClosed { request_id }, + ) + .await; +} + +fn local_url(local_address: SocketAddr, uri: &str, scheme: &str) -> Result { + if !uri.starts_with('/') || uri.starts_with("//") { + bail!("invalid relay request target"); + } + let url = Url::parse(&format!("{scheme}://{local_address}{uri}")) + .context("build local request URL")?; + let host_matches = match (url.host(), local_address.ip()) { + (Some(url::Host::Ipv4(actual)), IpAddr::V4(expected)) => actual == expected, + (Some(url::Host::Ipv6(actual)), IpAddr::V6(expected)) => actual == expected, + _ => false, + }; + if !host_matches || url.port() != Some(local_address.port()) { + bail!("relay request escaped the local service origin"); + } + Ok(url) +} + +async fn ensure_local_listener(port: u16) -> Result { + let addresses: [std::net::SocketAddr; 2] = [ + ([127, 0, 0, 1], port).into(), + ([0, 0, 0, 0, 0, 0, 0, 1], port).into(), + ]; + for address in addresses { + if matches!( + tokio::time::timeout(Duration::from_millis(500), TcpStream::connect(address)).await, + Ok(Ok(_)) + ) { + return Ok(address); + } + } + bail!( + "nothing is listening on localhost:{port}; start your app first, then run `sandbox http {port}`" + ) +} + +fn skip_local_header(name: &HeaderName) -> bool { + *name == http::header::HOST || is_hop_by_hop(name) +} + +fn skip_local_websocket_header(name: &HeaderName) -> bool { + skip_local_header(name) + || *name == http::header::ORIGIN + || matches!( + name.as_str(), + "sec-websocket-key" | "sec-websocket-version" | "sec-websocket-extensions" + ) +} + +fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn tungstenite_frame_to_relay(message: Message) -> RelayWebSocketFrame { + match message { + Message::Text(text) => RelayWebSocketFrame::Text(text.to_string()), + Message::Binary(data) => RelayWebSocketFrame::Binary(BASE64.encode(data)), + Message::Ping(data) => RelayWebSocketFrame::Ping(BASE64.encode(data)), + Message::Pong(data) => RelayWebSocketFrame::Pong(BASE64.encode(data)), + Message::Close(frame) => RelayWebSocketFrame::Close { + code: frame.as_ref().map(|frame| frame.code.into()), + reason: frame.map_or_else(String::new, |frame| frame.reason.to_string()), + }, + Message::Frame(_) => RelayWebSocketFrame::Close { + code: None, + reason: String::new(), + }, + } +} + +fn relay_frame_to_tungstenite(frame: RelayWebSocketFrame) -> Message { + match frame { + RelayWebSocketFrame::Text(text) => Message::Text(text.into()), + RelayWebSocketFrame::Binary(data) => { + Message::Binary(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Ping(data) => { + Message::Ping(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Pong(data) => { + Message::Pong(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Close { code, reason } => { + Message::Close(code.map(|code| CloseFrame { + code: CloseCode::from(code), + reason: reason.into(), + })) + } + } +} + +async fn send_protocol_message( + outbound: &mpsc::Sender, + message: &LocalRelayClientMessage, +) { + if let Ok(json) = serde_json::to_string(message) { + let _ = outbound.send(Message::Text(json.into())).await; + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + + use super::{ensure_local_listener, local_url}; + + #[test] + fn local_url_never_accepts_an_absolute_target() { + let local_address = SocketAddr::from((Ipv4Addr::LOCALHOST, 4321)); + assert!(local_url(local_address, "https://example.com/", "http").is_err()); + assert!(local_url(local_address, "//example.com/", "http").is_err()); + assert_eq!( + local_url(local_address, "/hello?name=world", "http") + .expect("local URL") + .as_str(), + "http://127.0.0.1:4321/hello?name=world" + ); + assert_eq!( + local_url( + SocketAddr::from((Ipv6Addr::LOCALHOST, 4321)), + "/hello?name=world", + "http" + ) + .expect("IPv6 local URL") + .as_str(), + "http://[::1]:4321/hello?name=world" + ); + } + + #[tokio::test] + async fn detects_a_local_listener() { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind local listener"); + let port = listener.local_addr().expect("listener address").port(); + let address = ensure_local_listener(port) + .await + .expect("detect local listener"); + assert_eq!(address, listener.local_addr().expect("listener address")); + } + + #[tokio::test] + async fn detects_an_ipv6_only_local_listener() { + let Ok(listener) = tokio::net::TcpListener::bind((Ipv6Addr::LOCALHOST, 0)).await else { + return; + }; + let expected = listener.local_addr().expect("listener address"); + let address = ensure_local_listener(expected.port()) + .await + .expect("detect IPv6 local listener"); + assert_eq!(address, expected); + } +} diff --git a/cmd/sandbox/src/main.rs b/cmd/sandbox/src/main.rs index eb92dd3..efcc52d 100644 --- a/cmd/sandbox/src/main.rs +++ b/cmd/sandbox/src/main.rs @@ -1,3 +1,5 @@ +mod local_http; + use std::{ collections::BTreeMap, time::{Duration, Instant}, @@ -68,6 +70,8 @@ enum Commands { #[command(subcommand)] command: TunnelCommands, }, + /// Share a local HTTP service at a temporary public URL. + Http(HttpArgs), /// Print MCP client configuration for sandbox-mcp. McpConfig, } @@ -124,6 +128,23 @@ struct ExecArgs { argv: Vec, } +#[derive(Debug, Args)] +struct HttpArgs { + /// Local HTTP service port to share. + #[arg(value_parser = parse_port)] + port: u16, + /// Hosted Sandbox relay. Override this for a self-hosted deployment. + #[arg( + long, + env = "SANDBOX_HTTP_RELAY", + default_value = "https://relay.tunnel.yshubham.com" + )] + relay: Url, + /// Optional temporary hostname label below the relay's wildcard domain. + #[arg(long)] + subdomain: Option, +} + #[derive(Debug, Subcommand)] enum AgentCommands { /// List built-in agent profiles. @@ -187,6 +208,9 @@ enum SensitivityArg { #[tokio::main] async fn main() -> Result<()> { + // Reqwest and the WebSocket relay share rustls. Select one process-wide + // provider explicitly so release builds never depend on feature inference. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let cli = Cli::parse(); let client = SandboxClient::new( cli.server.clone(), @@ -305,6 +329,16 @@ async fn main() -> Result<()> { } Commands::Agent { command } => run_agent(command, &client, cli.json).await?, Commands::Tunnel { command } => run_tunnel(command, &client, cli.json).await?, + Commands::Http(args) => { + local_http::run( + args.port, + args.relay, + args.subdomain, + cli.token.as_deref(), + cli.json, + ) + .await? + } Commands::McpConfig => { print_json( &serde_json::json!({"mcpServers":{"sandbox":{"command":"sandbox-mcp","env":{"SANDBOX_URL":cli.server,"SANDBOX_TOKEN":"use-your-client-secret-store"}}}}), @@ -556,3 +590,54 @@ fn parse_exposure(value: &str) -> Result { exposure.validate().map_err(|error| error.to_string())?; Ok(exposure) } + +fn parse_port(value: &str) -> Result { + let port = value + .parse::() + .map_err(|_| "port must be between 1 and 65535".to_owned())?; + if port == 0 { + return Err("port must be between 1 and 65535".into()); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_http_shortcut() { + let cli = + Cli::try_parse_from(["sandbox", "http", "3000"]).expect("parse sandbox http shortcut"); + let Commands::Http(args) = cli.command else { + panic!("expected HTTP command"); + }; + assert_eq!(args.port, 3000); + assert_eq!(args.relay.as_str(), "https://relay.tunnel.yshubham.com/"); + assert!(args.subdomain.is_none()); + } + + #[test] + fn http_shortcut_rejects_port_zero() { + assert!(Cli::try_parse_from(["sandbox", "http", "0"]).is_err()); + } + + #[test] + fn parses_custom_http_relay_and_subdomain() { + let cli = Cli::try_parse_from([ + "sandbox", + "http", + "4321", + "--relay", + "https://relay.sandbox.example", + "--subdomain", + "demo", + ]) + .expect("parse custom relay"); + let Commands::Http(args) = cli.command else { + panic!("expected HTTP command"); + }; + assert_eq!(args.relay.as_str(), "https://relay.sandbox.example/"); + assert_eq!(args.subdomain.as_deref(), Some("demo")); + } +} diff --git a/cmd/sandboxd/Cargo.toml b/cmd/sandboxd/Cargo.toml index a6a7c3d..1720ace 100644 --- a/cmd/sandboxd/Cargo.toml +++ b/cmd/sandboxd/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true axum.workspace = true +base64.workspace = true chrono.workspace = true clap.workspace = true futures-util.workspace = true @@ -28,6 +29,7 @@ secrecy.workspace = true serde.workspace = true serde_json.workspace = true subtle.workspace = true +tempfile.workspace = true tokio.workspace = true tokio-util.workspace = true tower-http.workspace = true diff --git a/cmd/sandboxd/src/controller.rs b/cmd/sandboxd/src/controller.rs index 22cd4fa..e6ae176 100644 --- a/cmd/sandboxd/src/controller.rs +++ b/cmd/sandboxd/src/controller.rs @@ -1,3 +1,5 @@ +mod local_relay; + use std::{collections::BTreeSet, sync::Arc, time::Duration as StdDuration}; use axum::{ @@ -40,6 +42,7 @@ struct AppState { store: StoreRef, bus: BusRef, scheduler: AegisScheduler, + local_relays: local_relay::LocalRelayRegistry, } pub async fn serve( @@ -50,11 +53,13 @@ pub async fn serve( cancel: CancellationToken, ) -> anyhow::Result<()> { let store_name = store.backend_name(); + let local_relays = local_relay::LocalRelayRegistry::initialize(&config.tunnel.config_dir)?; let state = AppState { config: config.clone(), store, bus, scheduler, + local_relays, }; let reaper_state = state.clone(); let reaper_cancel = cancel.clone(); @@ -74,11 +79,13 @@ pub async fn serve( axum::routing::delete(delete_tunnel), ) .route("/v1/tunnels/authorize", get(authorize_tunnel_domain)) + .route("/v1/local-tunnels/connect", get(local_relay::connect)) .route("/v1/operations/{id}", get(get_operation)) .route("/v1/nodes/register", post(register_node)) .route("/v1/nodes/{id}/heartbeat", post(heartbeat_node)) .route("/v1/nodes/{id}/assignments", get(lease_assignments)) .route("/v1/assignments/complete", post(complete_assignment)) + .fallback(local_relay::proxy) .layer(DefaultBodyLimit::max( config.server.request_body_limit_bytes, )) @@ -118,6 +125,16 @@ async fn health(State(state): State) -> Json { .enabled .then_some(vec![ExposureProtocol::Http]) .unwrap_or_default(), + local_relay_enabled: state.config.tunnel.enabled + && state.config.tunnel.local_relay_enabled, + local_relay_url: (state.config.tunnel.enabled + && state.config.tunnel.local_relay_enabled) + .then(|| { + state.config.tunnel.base_domain.as_ref().map(|domain| { + format!("{}://relay.{domain}", state.config.tunnel.public_scheme) + }) + }) + .flatten(), }, }) } @@ -354,6 +371,11 @@ async fn authorize_tunnel_domain( if !domain.ends_with(&suffix) || domain.len() <= suffix.len() { return StatusCode::NOT_FOUND; } + if state.config.tunnel.local_relay_enabled + && (domain == format!("relay{suffix}") || state.local_relays.contains(&domain).await) + { + return StatusCode::NO_CONTENT; + } match state.store.find_tunnel_by_hostname(&domain).await { Ok(Some(_)) => StatusCode::NO_CONTENT, Ok(None) | Err(_) => StatusCode::NOT_FOUND, diff --git a/cmd/sandboxd/src/controller/local_relay.rs b/cmd/sandboxd/src/controller/local_relay.rs new file mode 100644 index 0000000..736a5f6 --- /dev/null +++ b/cmd/sandboxd/src/controller/local_relay.rs @@ -0,0 +1,648 @@ +use std::{collections::HashMap, fs, io::Write, path::PathBuf, sync::Arc, time::Duration}; + +use axum::{ + body::{Body, to_bytes}, + extract::{ + FromRequestParts, Query, State, WebSocketUpgrade, + ws::{CloseFrame, Message, WebSocket}, + }, + http::{HeaderMap, HeaderName, HeaderValue, Request, StatusCode, Uri}, + response::{IntoResponse, Response}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use chrono::{Duration as ChronoDuration, Utc}; +use futures_util::{SinkExt, StreamExt}; +use sandbox_core::{ + api::{LocalRelayClientMessage, LocalRelayServerMessage, RelayWebSocketFrame}, + model::validate_dns_label, +}; +use serde::Deserialize; +use tempfile::NamedTempFile; +use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; +use tracing::{info, warn}; +use uuid::Uuid; + +use super::{ApiError, AppState, require_operator}; + +const RELAY_ROUTE_PREFIX: &str = "local-relay-"; +type PendingHttpResponses = Arc>>>; +type PendingWebSockets = Arc>>>>; +type WebSocketFrameSenders = Arc>>>; + +#[derive(Clone, Default)] +pub(super) struct LocalRelayRegistry { + sessions: Arc>>, +} + +#[derive(Clone)] +struct LocalRelaySession { + client_key: String, + outbound: mpsc::Sender, + pending_http: PendingHttpResponses, + pending_websockets: PendingWebSockets, + websocket_frames: WebSocketFrameSenders, +} + +struct RelayedHttpResponse { + status: u16, + headers: Vec<(String, String)>, + body_base64: String, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct ConnectQuery { + subdomain: Option, +} + +impl LocalRelayRegistry { + pub(super) fn initialize(config_dir: &str) -> anyhow::Result { + let directory = PathBuf::from(config_dir); + if let Ok(entries) = fs::read_dir(&directory) { + for entry in entries { + let entry = entry?; + if entry + .file_name() + .to_string_lossy() + .starts_with(RELAY_ROUTE_PREFIX) + && entry.path().extension().is_some_and(|value| value == "yml") + { + fs::remove_file(entry.path())?; + } + } + } + Ok(Self::default()) + } + + async fn get(&self, hostname: &str) -> Option { + self.sessions.read().await.get(hostname).cloned() + } + + pub(super) async fn contains(&self, hostname: &str) -> bool { + self.sessions.read().await.contains_key(hostname) + } +} + +pub(super) async fn connect( + State(state): State, + Query(query): Query, + headers: HeaderMap, + websocket: WebSocketUpgrade, +) -> Result { + if !state.config.tunnel.enabled || !state.config.tunnel.local_relay_enabled { + return Err(ApiError::service_unavailable( + "local HTTP relay is disabled on this Sandbox server", + )); + } + if state.config.tunnel.local_relay_require_auth { + require_operator(&state, &headers)?; + } + if let Some(subdomain) = &query.subdomain { + validate_dns_label(subdomain).map_err(ApiError::bad_request)?; + } + let client_key = relay_client_key(&headers); + let sessions = state.local_relays.sessions.read().await; + if sessions.len() >= state.config.tunnel.max_local_relays { + return Err(ApiError::service_unavailable( + "this Sandbox server has reached its local relay limit", + )); + } + let client_sessions = sessions + .values() + .filter(|session| session.client_key == client_key) + .count(); + drop(sessions); + if client_sessions >= state.config.tunnel.max_local_relays_per_client { + return Err(ApiError::conflict(format!( + "this client already has the maximum of {} local relays", + state.config.tunnel.max_local_relays_per_client + ))); + } + + Ok(websocket + .on_upgrade(move |socket| serve_connection(socket, state, query.subdomain, client_key))) +} + +pub(super) async fn proxy(State(state): State, request: Request) -> Response { + let hostname = match request_host(request.headers()) { + Some(hostname) => hostname, + None => return plain(StatusCode::BAD_REQUEST, "missing Host header"), + }; + let Some(session) = state.local_relays.get(&hostname).await else { + return plain(StatusCode::NOT_FOUND, "tunnel not found"); + }; + let uri = request.uri().clone(); + let headers = relay_headers(request.headers(), &hostname); + + if is_websocket_upgrade(request.headers()) { + let protocols = requested_protocols(request.headers()); + let (mut parts, _) = request.into_parts(); + let websocket = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await { + Ok(websocket) => websocket, + Err(rejection) => return rejection.into_response(), + }; + let websocket = if protocols.is_empty() { + websocket + } else { + websocket.protocols(protocols) + }; + return websocket.on_upgrade(move |socket| proxy_websocket(socket, session, uri, headers)); + } + + proxy_http(state, session, request, uri, headers).await +} + +async fn serve_connection( + mut socket: WebSocket, + state: AppState, + requested_subdomain: Option, + client_key: String, +) { + let base_domain = match state.config.tunnel.base_domain() { + Ok(domain) => domain.to_owned(), + Err(error) => { + send_socket_error(&mut socket, error.to_string()).await; + return; + } + }; + let session_id = Uuid::now_v7(); + let subdomain = requested_subdomain.unwrap_or_else(|| format!("local-{}", session_id.simple())); + let hostname = format!("{subdomain}.{base_domain}"); + let public_url = format!("{}://{hostname}", state.config.tunnel.public_scheme); + let route_path = relay_route_path(&state, session_id); + let (outbound, mut outbound_rx) = mpsc::channel(256); + let session = LocalRelaySession { + client_key, + outbound: outbound.clone(), + pending_http: Arc::default(), + pending_websockets: Arc::default(), + websocket_frames: Arc::default(), + }; + + { + let mut sessions = state.local_relays.sessions.write().await; + if sessions.contains_key(&hostname) { + send_socket_error( + &mut socket, + format!("subdomain {subdomain} is already in use"), + ) + .await; + return; + } + if let Err(error) = write_relay_route(&state, session_id, &hostname) { + warn!(%error, %hostname, "failed to create local relay edge route"); + send_socket_error(&mut socket, "failed to register the public edge route").await; + return; + } + sessions.insert(hostname.clone(), session.clone()); + } + + let ttl = Duration::from_secs(state.config.tunnel.local_relay_ttl_seconds); + let expires_at = Utc::now() + + ChronoDuration::seconds( + i64::try_from(state.config.tunnel.local_relay_ttl_seconds).unwrap_or(i64::MAX), + ); + if outbound + .send(LocalRelayServerMessage::Ready { + public_url: public_url.clone(), + hostname: hostname.clone(), + expires_at, + }) + .await + .is_err() + { + cleanup_connection(&state, &hostname, &route_path).await; + return; + } + + info!(%hostname, %expires_at, "local HTTP relay connected"); + let (mut socket_tx, mut socket_rx) = socket.split(); + let writer = tokio::spawn(async move { + let mut heartbeat = tokio::time::interval(Duration::from_secs(25)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + message = outbound_rx.recv() => { + let Some(message) = message else { return }; + let Ok(json) = serde_json::to_string(&message) else { return }; + if socket_tx.send(Message::Text(json.into())).await.is_err() { return; } + } + _ = heartbeat.tick() => { + if socket_tx.send(Message::Ping(Vec::new().into())).await.is_err() { return; } + } + } + } + }); + + let expires = tokio::time::sleep(ttl); + tokio::pin!(expires); + loop { + tokio::select! { + _ = &mut expires => break, + message = socket_rx.next() => { + let Some(Ok(message)) = message else { break }; + match message { + Message::Text(text) => { + match serde_json::from_str::(&text) { + Ok(message) => handle_client_message(&session, message).await, + Err(error) => warn!(%error, %hostname, "ignored invalid local relay message"), + } + } + Message::Close(_) => break, + Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => {} + } + } + } + } + + writer.abort(); + cleanup_connection(&state, &hostname, &route_path).await; + info!(%hostname, "local HTTP relay disconnected"); +} + +async fn proxy_http( + state: AppState, + session: LocalRelaySession, + request: Request, + uri: Uri, + headers: Vec<(String, String)>, +) -> Response { + let request_id = Uuid::now_v7(); + let method = request.method().to_string(); + let body = match to_bytes( + request.into_body(), + state.config.tunnel.local_relay_body_limit_bytes, + ) + .await + { + Ok(body) => body, + Err(_) => return plain(StatusCode::PAYLOAD_TOO_LARGE, "request body is too large"), + }; + let (response_tx, response_rx) = oneshot::channel(); + session + .pending_http + .lock() + .await + .insert(request_id, response_tx); + let message = LocalRelayServerMessage::HttpRequest { + request_id, + method, + uri: uri.to_string(), + headers, + body_base64: BASE64.encode(body), + }; + if session.outbound.send(message).await.is_err() { + session.pending_http.lock().await.remove(&request_id); + return plain(StatusCode::BAD_GATEWAY, "local service disconnected"); + } + let response = match tokio::time::timeout(Duration::from_secs(30), response_rx).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => return plain(StatusCode::BAD_GATEWAY, "local service disconnected"), + Err(_) => { + session.pending_http.lock().await.remove(&request_id); + return plain(StatusCode::GATEWAY_TIMEOUT, "local service timed out"); + } + }; + let body = match BASE64.decode(response.body_base64) { + Ok(body) if body.len() <= state.config.tunnel.local_relay_body_limit_bytes => body, + _ => { + return plain( + StatusCode::BAD_GATEWAY, + "local service returned an invalid body", + ); + } + }; + let status = match StatusCode::from_u16(response.status) { + Ok(status) => status, + Err(_) => { + return plain( + StatusCode::BAD_GATEWAY, + "local service returned an invalid status", + ); + } + }; + let mut output = Response::builder().status(status); + if let Some(output_headers) = output.headers_mut() { + for (name, value) in response.headers { + let Ok(name) = HeaderName::try_from(name) else { + continue; + }; + if is_hop_by_hop(&name) { + continue; + } + let Ok(value) = HeaderValue::try_from(value) else { + continue; + }; + output_headers.append(name, value); + } + } + output.body(Body::from(body)).unwrap_or_else(|_| { + plain( + StatusCode::BAD_GATEWAY, + "could not build the local service response", + ) + }) +} + +async fn proxy_websocket( + socket: WebSocket, + session: LocalRelaySession, + uri: Uri, + headers: Vec<(String, String)>, +) { + let request_id = Uuid::now_v7(); + let (ready_tx, ready_rx) = oneshot::channel(); + session + .pending_websockets + .lock() + .await + .insert(request_id, ready_tx); + let (frame_tx, mut frame_rx) = mpsc::channel(256); + session + .websocket_frames + .lock() + .await + .insert(request_id, frame_tx); + if session + .outbound + .send(LocalRelayServerMessage::WebSocketOpen { + request_id, + uri: uri.to_string(), + headers, + }) + .await + .is_err() + { + cleanup_websocket(&session, request_id).await; + return; + } + match tokio::time::timeout(Duration::from_secs(10), ready_rx).await { + Ok(Ok(Ok(()))) => {} + _ => { + cleanup_websocket(&session, request_id).await; + return; + } + } + + let (mut public_tx, mut public_rx) = socket.split(); + loop { + tokio::select! { + message = public_rx.next() => { + let Some(Ok(message)) = message else { break }; + let frame = axum_frame_to_relay(message); + let closing = matches!(frame, RelayWebSocketFrame::Close { .. }); + if session.outbound.send(LocalRelayServerMessage::WebSocketFrame { request_id, frame }).await.is_err() || closing { + break; + } + } + frame = frame_rx.recv() => { + let Some(frame) = frame else { break }; + let closing = matches!(frame, RelayWebSocketFrame::Close { .. }); + if public_tx.send(relay_frame_to_axum(frame)).await.is_err() || closing { + break; + } + } + } + } + let _ = session + .outbound + .send(LocalRelayServerMessage::WebSocketClose { request_id }) + .await; + cleanup_websocket(&session, request_id).await; +} + +async fn handle_client_message(session: &LocalRelaySession, message: LocalRelayClientMessage) { + match message { + LocalRelayClientMessage::HttpResponse { + request_id, + status, + headers, + body_base64, + } => { + if let Some(sender) = session.pending_http.lock().await.remove(&request_id) { + let _ = sender.send(RelayedHttpResponse { + status, + headers, + body_base64, + }); + } + } + LocalRelayClientMessage::WebSocketReady { request_id, error } => { + if let Some(sender) = session.pending_websockets.lock().await.remove(&request_id) { + let _ = sender.send(error.map_or(Ok(()), Err)); + } + } + LocalRelayClientMessage::WebSocketFrame { request_id, frame } => { + let sender = session + .websocket_frames + .lock() + .await + .get(&request_id) + .cloned(); + if let Some(sender) = sender { + let _ = sender.send(frame).await; + } + } + LocalRelayClientMessage::WebSocketClosed { request_id } => { + if let Some(sender) = session.websocket_frames.lock().await.remove(&request_id) { + let _ = sender + .send(RelayWebSocketFrame::Close { + code: None, + reason: String::new(), + }) + .await; + } + } + } +} + +async fn cleanup_websocket(session: &LocalRelaySession, request_id: Uuid) { + session.pending_websockets.lock().await.remove(&request_id); + session.websocket_frames.lock().await.remove(&request_id); +} + +async fn cleanup_connection(state: &AppState, hostname: &str, route_path: &PathBuf) { + state.local_relays.sessions.write().await.remove(hostname); + if let Err(error) = fs::remove_file(route_path) + && error.kind() != std::io::ErrorKind::NotFound + { + warn!(%error, %hostname, "failed to remove local relay edge route"); + } +} + +fn write_relay_route(state: &AppState, session_id: Uuid, hostname: &str) -> anyhow::Result<()> { + let directory = PathBuf::from(&state.config.tunnel.config_dir); + fs::create_dir_all(&directory)?; + let route_name = format!("local-{}", session_id.simple()); + let body = format!( + "http:\n routers:\n {route_name}:\n rule: \"Host(`{hostname}`)\"\n entryPoints:\n - {entrypoint}\n service: {route_name}\n services:\n {route_name}:\n loadBalancer:\n passHostHeader: true\n servers:\n - url: \"{upstream}\"\n", + entrypoint = state.config.tunnel.edge_entrypoint, + upstream = state.config.tunnel.local_relay_upstream, + ); + let mut temporary = NamedTempFile::new_in(&directory)?; + temporary.write_all(body.as_bytes())?; + temporary.flush()?; + temporary.persist(relay_route_path(state, session_id))?; + Ok(()) +} + +fn relay_route_path(state: &AppState, session_id: Uuid) -> PathBuf { + PathBuf::from(&state.config.tunnel.config_dir) + .join(format!("{RELAY_ROUTE_PREFIX}{}.yml", session_id.simple())) +} + +fn relay_client_key(headers: &HeaderMap) -> String { + for name in ["cf-connecting-ip", "x-forwarded-for"] { + if let Some(value) = headers.get(name).and_then(|value| value.to_str().ok()) + && let Some(first) = value.split(',').next() + { + let first = first.trim(); + if !first.is_empty() { + return first.chars().take(128).collect(); + } + } + } + "unknown".into() +} + +fn request_host(headers: &HeaderMap) -> Option { + headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(':').next()) + .map(|value| value.trim_end_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn relay_headers(headers: &HeaderMap, public_host: &str) -> Vec<(String, String)> { + let mut output = headers + .iter() + .filter(|(name, _)| **name != axum::http::header::HOST && !is_hop_by_hop(name)) + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.to_string(), value.to_owned())) + }) + .collect::>(); + output.push(("x-forwarded-host".into(), public_host.into())); + output.push(("x-forwarded-proto".into(), "https".into())); + output +} + +fn requested_protocols(headers: &HeaderMap) -> Vec { + headers + .get(axum::http::header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + headers + .get(axum::http::header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) + && headers + .get(axum::http::header::CONNECTION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + }) +} + +fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn axum_frame_to_relay(message: Message) -> RelayWebSocketFrame { + match message { + Message::Text(text) => RelayWebSocketFrame::Text(text.to_string()), + Message::Binary(data) => RelayWebSocketFrame::Binary(BASE64.encode(data)), + Message::Ping(data) => RelayWebSocketFrame::Ping(BASE64.encode(data)), + Message::Pong(data) => RelayWebSocketFrame::Pong(BASE64.encode(data)), + Message::Close(frame) => RelayWebSocketFrame::Close { + code: frame.as_ref().map(|frame| frame.code), + reason: frame.map_or_else(String::new, |frame| frame.reason.to_string()), + }, + } +} + +fn relay_frame_to_axum(frame: RelayWebSocketFrame) -> Message { + match frame { + RelayWebSocketFrame::Text(text) => Message::Text(text.into()), + RelayWebSocketFrame::Binary(data) => { + Message::Binary(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Ping(data) => { + Message::Ping(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Pong(data) => { + Message::Pong(BASE64.decode(data).unwrap_or_default().into()) + } + RelayWebSocketFrame::Close { code, reason } => { + Message::Close(code.map(|code| CloseFrame { + code, + reason: reason.into(), + })) + } + } +} + +async fn send_socket_error(socket: &mut WebSocket, message: impl Into) { + let message = LocalRelayServerMessage::Error { + message: message.into(), + }; + if let Ok(json) = serde_json::to_string(&message) { + let _ = socket.send(Message::Text(json.into())).await; + } + let _ = socket.close().await; +} + +fn plain(status: StatusCode, message: &'static str) -> Response { + (status, message).into_response() +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + + use super::{relay_client_key, request_host}; + + #[test] + fn normalizes_public_host() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("Demo.Tunnel.Example:443")); + assert_eq!( + request_host(&headers).as_deref(), + Some("demo.tunnel.example") + ); + } + + #[test] + fn prefers_cloudflare_client_address() { + let mut headers = HeaderMap::new(); + headers.insert("cf-connecting-ip", HeaderValue::from_static("203.0.113.8")); + headers.insert("x-forwarded-for", HeaderValue::from_static("192.0.2.2")); + assert_eq!(relay_client_key(&headers), "203.0.113.8"); + } +} diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index bfa9e35..5a14b24 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ AssignmentId, NodeId, OperationId, SandboxId, @@ -33,6 +34,75 @@ pub struct TunnelCapabilities { pub public_scheme: Option, #[serde(default)] pub protocols: Vec, + #[serde(default)] + pub local_relay_enabled: bool, + pub local_relay_url: Option, +} + +/// Messages sent from the hosted relay to a connected `sandbox http` client. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LocalRelayServerMessage { + Ready { + public_url: String, + hostname: String, + expires_at: DateTime, + }, + HttpRequest { + request_id: Uuid, + method: String, + uri: String, + headers: Vec<(String, String)>, + body_base64: String, + }, + WebSocketOpen { + request_id: Uuid, + uri: String, + headers: Vec<(String, String)>, + }, + WebSocketFrame { + request_id: Uuid, + frame: RelayWebSocketFrame, + }, + WebSocketClose { + request_id: Uuid, + }, + Error { + message: String, + }, +} + +/// Messages sent from the local CLI back to the hosted relay. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LocalRelayClientMessage { + HttpResponse { + request_id: Uuid, + status: u16, + headers: Vec<(String, String)>, + body_base64: String, + }, + WebSocketReady { + request_id: Uuid, + error: Option, + }, + WebSocketFrame { + request_id: Uuid, + frame: RelayWebSocketFrame, + }, + WebSocketClosed { + request_id: Uuid, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum RelayWebSocketFrame { + Text(String), + Binary(String), + Ping(String), + Pong(String), + Close { code: Option, reason: String }, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 1171684..8c29e4d 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -46,6 +46,27 @@ pub struct TunnelConfig { pub edge_cert_resolver: Option, #[serde(default = "default_max_tunnels")] pub max_per_sandbox: usize, + /// Allow ephemeral tunnels from a developer workstation over the relay WebSocket. + #[serde(default)] + pub local_relay_enabled: bool, + /// Require the operator bearer token before accepting a local relay connection. + #[serde(default = "default_true")] + pub local_relay_require_auth: bool, + /// Internal URL used by the HTTP edge for exact-host local relay routes. + #[serde(default = "default_local_relay_upstream")] + pub local_relay_upstream: String, + /// Maximum number of simultaneous local relay sessions on one controller. + #[serde(default = "default_max_local_relays")] + pub max_local_relays: usize, + /// Maximum simultaneous local relays attributed to one client address. + #[serde(default = "default_max_local_relays_per_client")] + pub max_local_relays_per_client: usize, + /// Maximum request or response body transported by the HTTP relay. + #[serde(default = "default_local_relay_body_limit")] + pub local_relay_body_limit_bytes: usize, + /// Hard lifetime for an ephemeral local relay session. + #[serde(default = "default_local_relay_ttl")] + pub local_relay_ttl_seconds: u64, } impl Default for TunnelConfig { @@ -61,6 +82,13 @@ impl Default for TunnelConfig { edge_tls: true, edge_cert_resolver: default_tunnel_cert_resolver(), max_per_sandbox: default_max_tunnels(), + local_relay_enabled: false, + local_relay_require_auth: true, + local_relay_upstream: default_local_relay_upstream(), + max_local_relays: default_max_local_relays(), + max_local_relays_per_client: default_max_local_relays_per_client(), + local_relay_body_limit_bytes: default_local_relay_body_limit(), + local_relay_ttl_seconds: default_local_relay_ttl(), } } } @@ -114,6 +142,37 @@ impl TunnelConfig { "tunnel.max_per_sandbox must be between 1 and 32".into(), )); } + let relay_upstream = Url::parse(&self.local_relay_upstream).map_err(|_| { + CoreError::Validation("tunnel.local_relay_upstream must be a valid HTTP URL".into()) + })?; + if relay_upstream.scheme() != "http" + || relay_upstream.host_str().is_none() + || relay_upstream.cannot_be_a_base() + || relay_upstream.username() != "" + || relay_upstream.password().is_some() + { + return Err(CoreError::Validation( + "tunnel.local_relay_upstream must be an internal HTTP URL without credentials" + .into(), + )); + } + if !(1..=10_000).contains(&self.max_local_relays) + || !(1..=100).contains(&self.max_local_relays_per_client) + { + return Err(CoreError::Validation( + "local relay limits are outside the supported range".into(), + )); + } + if !(65_536..=67_108_864).contains(&self.local_relay_body_limit_bytes) { + return Err(CoreError::Validation( + "tunnel.local_relay_body_limit_bytes must be between 64 KiB and 64 MiB".into(), + )); + } + if !(60..=86_400).contains(&self.local_relay_ttl_seconds) { + return Err(CoreError::Validation( + "tunnel.local_relay_ttl_seconds must be between 60 seconds and 24 hours".into(), + )); + } Ok(()) } @@ -162,12 +221,27 @@ fn default_tunnel_entrypoint() -> String { fn default_tunnel_cert_resolver() -> Option { Some("letsencrypt".into()) } +fn default_local_relay_upstream() -> String { + "http://controller:8080".into() +} const fn default_true() -> bool { true } const fn default_max_tunnels() -> usize { 8 } +const fn default_max_local_relays() -> usize { + 100 +} +const fn default_max_local_relays_per_client() -> usize { + 3 +} +const fn default_local_relay_body_limit() -> usize { + 16 * 1_048_576 +} +const fn default_local_relay_ttl() -> u64 { + 14_400 +} impl SandboxConfig { pub fn load(path: Option<&Path>) -> CoreResult { diff --git a/deploy/caddy/Caddyfile.cloudflare-http.example b/deploy/caddy/Caddyfile.cloudflare-http.example index 00e6596..59d1bb9 100644 --- a/deploy/caddy/Caddyfile.cloudflare-http.example +++ b/deploy/caddy/Caddyfile.cloudflare-http.example @@ -7,6 +7,10 @@ reverse_proxy controller:8080 } +http://relay.{$SANDBOX_TUNNEL_DOMAIN} { + reverse_proxy controller:8080 +} + # Compatibility mode for a fixed, proxied, multi-level Cloudflare wildcard # that does not have an Advanced edge certificate. Visitor traffic is HTTP. http://*.{$SANDBOX_TUNNEL_DOMAIN} { diff --git a/deploy/caddy/Caddyfile.cloudflare-origin.example b/deploy/caddy/Caddyfile.cloudflare-origin.example index e274a98..3db694a 100644 --- a/deploy/caddy/Caddyfile.cloudflare-origin.example +++ b/deploy/caddy/Caddyfile.cloudflare-origin.example @@ -7,6 +7,11 @@ reverse_proxy controller:8080 } +https://relay.{$SANDBOX_TUNNEL_DOMAIN} { + tls /run/secrets/cloudflare_origin_cert /run/secrets/cloudflare_origin_key + reverse_proxy controller:8080 +} + # Cloudflare terminates visitor TLS with the edge certificate. Caddy presents # the matching Origin CA certificate so the Cloudflare-to-origin leg can stay # on Full (strict) instead of being downgraded to Flexible. diff --git a/deploy/caddy/Caddyfile.example b/deploy/caddy/Caddyfile.example index 5f11d20..d8d3178 100644 --- a/deploy/caddy/Caddyfile.example +++ b/deploy/caddy/Caddyfile.example @@ -10,6 +10,13 @@ reverse_proxy controller:8080 } +relay.{$SANDBOX_TUNNEL_DOMAIN} { + tls { + on_demand + } + reverse_proxy controller:8080 +} + *.{$SANDBOX_TUNNEL_DOMAIN} { tls { on_demand diff --git a/deploy/compose/compose.cloudflare.yaml b/deploy/compose/compose.cloudflare.yaml index e31ce3b..20006ac 100644 --- a/deploy/compose/compose.cloudflare.yaml +++ b/deploy/compose/compose.cloudflare.yaml @@ -2,6 +2,7 @@ name: sandbox # Outbound-only Cloudflare ingress. Configure the remotely managed tunnel with: # SANDBOX_DOMAIN -> http://controller:8080 +# relay.SANDBOX_TUNNEL_DOMAIN -> http://controller:8080 # *.SANDBOX_TUNNEL_DOMAIN -> http://tunnel-edge:8080 # # Keep SANDBOX_PORT=127.0.0.1:8080, SANDBOX_TUNNEL_ENTRYPOINT=web, and diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index f718466..9a1f9b7 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -1,6 +1,13 @@ name: sandbox services: + tunnel-routes-init: + image: busybox:1.37 + command: ["sh", "-c", "chown 10001:10001 /routes && chmod 0750 /routes"] + volumes: + - tunnel-routes:/routes + restart: "no" + postgres: image: postgres:18-alpine environment: @@ -38,12 +45,23 @@ services: SANDBOX__TUNNEL__EDGE_TLS: ${SANDBOX_TUNNEL_EDGE_TLS:-true} SANDBOX__TUNNEL__EDGE_CERT_RESOLVER: ${SANDBOX_TUNNEL_CERT_RESOLVER:-letsencrypt} SANDBOX__TUNNEL__MAX_PER_SANDBOX: ${SANDBOX_TUNNEL_MAX_PER_SANDBOX:-8} + SANDBOX__TUNNEL__LOCAL_RELAY_ENABLED: ${SANDBOX_LOCAL_RELAY_ENABLED:-false} + SANDBOX__TUNNEL__LOCAL_RELAY_REQUIRE_AUTH: ${SANDBOX_LOCAL_RELAY_REQUIRE_AUTH:-true} + SANDBOX__TUNNEL__LOCAL_RELAY_UPSTREAM: ${SANDBOX_LOCAL_RELAY_UPSTREAM:-http://controller:8080} + SANDBOX__TUNNEL__MAX_LOCAL_RELAYS: ${SANDBOX_MAX_LOCAL_RELAYS:-100} + SANDBOX__TUNNEL__MAX_LOCAL_RELAYS_PER_CLIENT: ${SANDBOX_MAX_LOCAL_RELAYS_PER_CLIENT:-3} + SANDBOX__TUNNEL__LOCAL_RELAY_BODY_LIMIT_BYTES: ${SANDBOX_LOCAL_RELAY_BODY_LIMIT_BYTES:-16777216} + SANDBOX__TUNNEL__LOCAL_RELAY_TTL_SECONDS: ${SANDBOX_LOCAL_RELAY_TTL_SECONDS:-14400} RUST_LOG: ${RUST_LOG:-sandboxd=info,tower_http=info} ports: - "${SANDBOX_PORT:-8080}:8080" depends_on: + tunnel-routes-init: + condition: service_completed_successfully postgres: condition: service_healthy + volumes: + - tunnel-routes:/var/lib/sandbox/tunnels healthcheck: test: ["CMD", "sandbox", "--server", "http://127.0.0.1:8080", "doctor"] interval: 5s diff --git a/docs/api.md b/docs/api.md index 52d9de8..fa7a896 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,7 @@ The controller serves JSON over HTTP. Put TLS and enterprise identity at the edg | `DELETE` | `/v1/sandboxes/{id}` | operator | Enqueue teardown | | `GET` | `/v1/operations/{id}` | operator | Poll operation | | `GET` | `/v1/tunnels/authorize?domain=...` | public edge | Return `204` only for an active exact hostname | +| `GET` WebSocket | `/v1/local-tunnels/connect` | operator or enabled anonymous relay client | Create one ephemeral local HTTP/WebSocket relay session | | `POST` | `/v1/nodes/register` | worker | Upsert capacity/capabilities | | `POST` | `/v1/nodes/{id}/heartbeat` | worker | Update availability and pressure | | `GET` | `/v1/nodes/{id}/assignments` | worker | Lease work | @@ -37,4 +38,6 @@ Create, exec, tunnel changes, and delete are asynchronous. A successful enqueue `POST /v1/sandboxes` accepts optional `spec.exposures` entries containing `container_port`, `protocol: "http"`, optional lowercase `subdomain`, and `authenticated: false`. Post-create tunnel creation accepts the same fields. Every returned `public_url` is Internet-facing. See [tunnels.md](tunnels.md) for supported protocols and policy restrictions. +`GET /v1/local-tunnels/connect` is available only when `tunnel.local_relay_enabled` is true. It upgrades to the versioned JSON relay WebSocket used by `sandbox http PORT`; deployments require the operator token by default and may explicitly allow anonymous relay creation. The resulting public URL never inherits API authentication. + The Rust structs under `crates/core/src/api.rs` and `model.rs` are the canonical v0.1 schema. OpenAPI generation and streaming transports are roadmap items. diff --git a/docs/cli.md b/docs/cli.md index 4cc83b4..67da683 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,9 +57,21 @@ sandbox delete "$ID" --wait Deletion removes runtime resources. The stopped control-plane record remains for audit. -## Public tunnels +## Share a local service -The service must bind `0.0.0.0` inside the sandbox. Then create, inspect, and remove its public route: +```sh +sandbox http 3000 +sandbox http 4321 --subdomain design-review +SANDBOX_HTTP_RELAY=https://relay.tunnel.example.com sandbox http 8080 +``` + +`sandbox http PORT` checks that the port is listening on local IPv4 or IPv6, connects to the hosted Sandbox relay over an outbound WebSocket, prints the temporary HTTPS URL, and stays attached until Ctrl-C. The default relay is `https://relay.tunnel.yshubham.com`; `SANDBOX_HTTP_RELAY` or `--relay` selects a self-hosted deployment. `SANDBOX_TOKEN` is sent when the selected relay requires operator authentication. + +The relay supports ordinary HTTP plus WebSocket upgrades such as Vite HMR. It detects whether the app is listening on IPv4 (`127.0.0.1`) or IPv6 (`::1`) and forwards to that exact loopback address. It deliberately does not preserve the public `Host` or `Origin`, so a development server never has to trust a random hostname. The route is exact-host, expires at the server TTL, and is removed immediately when the CLI disconnects. The URL is unauthenticated and public; do not share admin panels, credentials, or private data. + +## Public tunnels from managed sandboxes + +A service inside a managed sandbox must bind `0.0.0.0`. Then create, inspect, and remove its controller-managed route: ```sh sandbox tunnel create "$ID" --port 3000 @@ -68,7 +80,7 @@ sandbox tunnel list "$ID" sandbox tunnel delete "$ID" "$TUNNEL_ID" ``` -Tunnel mutations wait by default; pass `--no-wait` to manage the operation separately. Treat every printed URL as public. See [tunnels.md](tunnels.md). +Controller-managed tunnel mutations wait by default; pass `--no-wait` to manage the operation separately. The server enforces sensitivity, worker capability, tunnel count, and protocol policy. Use `--subdomain` only for a stable label. See [tunnels.md](tunnels.md). ## Agents diff --git a/docs/configuration.md b/docs/configuration.md index ce5a190..1ac492f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,20 @@ SANDBOX__TUNNEL__BASE_DOMAIN=tunnel.example.com SANDBOX__TUNNEL__PUBLIC_SCHEME=https ``` +An operator may additionally enable workstation-to-edge sharing for `sandbox http PORT`: + +```text +SANDBOX__TUNNEL__LOCAL_RELAY_ENABLED=true +SANDBOX__TUNNEL__LOCAL_RELAY_REQUIRE_AUTH=true +SANDBOX__TUNNEL__LOCAL_RELAY_UPSTREAM=http://controller:8080 +SANDBOX__TUNNEL__MAX_LOCAL_RELAYS=100 +SANDBOX__TUNNEL__MAX_LOCAL_RELAYS_PER_CLIENT=3 +SANDBOX__TUNNEL__LOCAL_RELAY_BODY_LIMIT_BYTES=16777216 +SANDBOX__TUNNEL__LOCAL_RELAY_TTL_SECONDS=14400 +``` + +The Compose stack shares the exact-host route directory between the controller and edge and initializes it with controller-only write permissions. Keep authentication required for private installations. An intentionally public developer relay may disable connection authentication, but its URLs remain unauthenticated and Internet-facing; place Cloudflare/WAF rate controls in front and keep the session limits conservative. + See [tunnels.md](tunnels.md) for every key, wildcard DNS, direct Traefik, Caddy on-demand TLS, proxied Cloudflare Full (strict), outbound Cloudflare Tunnel ingress, lifecycle behavior, and troubleshooting. The task-oriented [custom public domains guide](how-to-setup/custom-public-domains.md) includes the complete certificate and verification flow. `SANDBOX_PORT=127.0.0.1:8080` limits the optional Compose host port to loopback when a private connector is the only ingress path. Keep `tunnel.public_scheme = "https"` for normal deployments. Set it to `http` only for the documented fixed-proxied-wildcard compatibility mode, and pair it with an HTTP edge entrypoint and disabled edge TLS so returned URLs match reality. diff --git a/docs/deployment.md b/docs/deployment.md index 724992e..5e29df8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -108,7 +108,7 @@ The installer supports Linux and macOS on amd64/arm64 and installs `sandbox`, `s Tagged releases also receive signed SLSA provenance through GitHub artifact attestations. Verify a downloaded archive before installation: ```sh -gh attestation verify sandbox_v0.1.1_linux_amd64.tar.gz --repo bas3line/sandbox +gh attestation verify sandbox_v0.1.5_linux_amd64.tar.gz --repo bas3line/sandbox ``` ## PostgreSQL diff --git a/docs/how-to-setup/client.md b/docs/how-to-setup/client.md index b3f15a8..c798a06 100644 --- a/docs/how-to-setup/client.md +++ b/docs/how-to-setup/client.md @@ -40,6 +40,14 @@ sandbox agent list `sandbox doctor` must report the expected controller version and tunnel configuration before an agent creates resources. +To share a service already running on the workstation, no managed sandbox is needed: + +```sh +sandbox http 4321 +``` + +The public URL uses the hosted `*.tunnel.yshubham.com` wildcard by default. A self-hosted installation sets `SANDBOX_HTTP_RELAY=https://relay.tunnel.example.com`. Ctrl-C revokes the route. + ## 4. Register MCP clients All native clients launch the same local stdio process. Existing entries should point to the absolute `sandbox-mcp` path when desktop applications do not inherit the shell `PATH`. A local wrapper that reads the controller URL and token from the operating-system secret store is also valid and avoids plaintext agent configuration. diff --git a/docs/how-to-setup/custom-public-domains.md b/docs/how-to-setup/custom-public-domains.md index d9ae37c..664ce91 100644 --- a/docs/how-to-setup/custom-public-domains.md +++ b/docs/how-to-setup/custom-public-domains.md @@ -143,6 +143,7 @@ For a private origin, create a remotely managed Cloudflare Tunnel and publish th | Public hostname | Internal service | |---|---| | `sandbox.example.com` | `http://controller:8080` | +| `relay.tunnel.example.com` | `http://controller:8080` | | `*.tunnel.example.com` | `http://tunnel-edge:8080` | Store the connector token only in a mode-`0600` file and start the outbound overlay: @@ -154,6 +155,8 @@ SANDBOX_TUNNEL_DOMAIN=tunnel.example.com SANDBOX_TUNNEL_SCHEME=https SANDBOX_TUNNEL_ENTRYPOINT=web SANDBOX_TUNNEL_EDGE_TLS=false +SANDBOX_LOCAL_RELAY_ENABLED=true +SANDBOX_LOCAL_RELAY_REQUIRE_AUTH=true CLOUDFLARE_TUNNEL_TOKEN_FILE=/etc/sandbox/cloudflare-tunnel.token ``` diff --git a/docs/security.md b/docs/security.md index 638da66..b91abf9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,8 @@ For production, place the controller behind an OIDC/mTLS gateway that maps ident Public tunnels are an independent ingress choice. The Docker worker creates one internal network per tunneled sandbox and attaches only that sandbox and the edge. Routes use exact hostnames and are removed with the tunnel or sandbox. URLs are unauthenticated and Internet-facing; confidential/restricted workloads, raw TCP, and requested tunnel authentication are rejected. Put an identity-aware proxy in front if your deployment needs user authentication. +Local relay tunnels are also opt-in and Internet-facing. The controller creates one unguessable exact-host route only after an outbound CLI WebSocket connects, bounds the global/per-client session count and relayed body size, applies a hard TTL, and deletes the route on disconnect or restart. The CLI targets only the selected loopback port and rejects absolute request targets. A deployment can require the operator bearer token for relay creation; that token does not authenticate visitors to the resulting URL. Anonymous relay creation is appropriate only for an intentionally public developer service behind provider-level WAF and rate controls. + Orange-cloud DNS alone is not a complete origin boundary: a public origin can still be discovered or reached outside Cloudflare. The Cloudflare Tunnel overlay uses an outbound-only connector and an internal edge with no published ingress ports. After end-to-end verification, enforce the boundary with host and provider firewalls so the controller, port 80, and port 443 are not publicly reachable. Keep the connector token in a file-backed secret and rotate it if exposed. The fixed proxied wildcard compatibility profile returns HTTP URLs and provides no transport confidentiality. It exists only for operators who cannot change a proxied multi-level record or provision its edge certificate. Never use it for credentials, private source, authenticated sessions, or confidential/restricted workloads. A proxied DNS answer hides the origin address from ordinary lookup but does not prove that the origin cannot be discovered or bypassed. @@ -60,7 +62,7 @@ The fixed proxied wildcard compatibility profile returns HTTP URLs and provides - No built-in secret broker, image signature verifier, or domain egress proxy. - PostgreSQL writes are not yet wrapped in one transaction/outbox. - Worker startup does not yet reconcile pre-existing runtime instances. -- Public HTTP/WebSocket tunnels do not include built-in user authentication or abuse/rate limiting. +- Public HTTP/WebSocket URLs do not include visitor authentication, bandwidth quotas, or durable distributed abuse controls. Treat these as explicit engineering gates, not documentation footnotes. diff --git a/docs/tunnels.md b/docs/tunnels.md index ff0fd05..655dd84 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -10,6 +10,19 @@ For a sandbox created with denied networking, the worker temporarily detaches Do The current implementation supports public HTTP and WebSocket services. It deliberately rejects raw TCP and `authenticated = true` until those paths have real enforcement. It also rejects public tunnels for `confidential` or `restricted` workloads. +## Share a workstation service + +`sandbox http PORT` is the ephemeral local counterpart to a managed sandbox tunnel. The CLI opens one outbound WebSocket to `relay.`, the controller creates a collision-resistant exact-host route, and the edge sends requests back over that session to the detected IPv4 (`127.0.0.1`) or IPv6 (`::1`) loopback listener. Regular HTTP and WebSocket upgrades are supported. The local request uses the loopback host and omits the public Origin, which lets Vite and similar development servers keep their host allowlist intact. + +```sh +sandbox http 4321 +# https://local-.tunnel.example.com +``` + +The command must remain running. Ctrl-C, relay disconnect, controller restart, or the configured TTL removes the route. `--subdomain` requests a temporary readable label; it does not reserve ownership. The public URL has no visitor authentication. + +Self-hosted deployments enable `tunnel.local_relay_enabled`, reserve `relay.` to the controller, and share `tunnel.config_dir` with the HTTP edge. Requiring the operator bearer token is the safe default. If an operator intentionally allows anonymous relay creation, provider-level WAF/rate limits and conservative controller session limits are required. + ```mermaid flowchart LR U["Public client"] --> C["Cloud or direct TLS edge"] @@ -104,6 +117,7 @@ flowchart LR | Public hostname | Internal service | |---|---| | `sandbox.example.com` | `http://controller:8080` | + | `relay.tunnel.example.com` | `http://controller:8080` | | `*.tunnel.example.com` | `http://tunnel-edge:8080` | Let Cloudflare create the proxied `CNAME` records to `.cfargotunnel.com`. Remove any conflicting origin-address `A`/`AAAA` record only after the connector and replacement routes are ready. diff --git a/skills/sandbox-platform/SKILL.md b/skills/sandbox-platform/SKILL.md index 3b89cd5..c561203 100644 --- a/skills/sandbox-platform/SKILL.md +++ b/skills/sandbox-platform/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbox-platform -description: Operate and configure self-hosted Sandbox environments through the sandbox CLI or sandbox-mcp. Use when creating disposable remote coding workspaces, executing untrusted repositories or generated code away from the host, publishing an HTTP or WebSocket service, configuring custom wildcard domains or Cloudflare HTTPS ingress, launching Codex, Claude Code, OpenCode, Pi, Aider, Goose, or CommandCode, inspecting asynchronous lifecycle operations, diagnosing scheduling or capacity, and cleaning up remote sandboxes. +description: Operate and configure self-hosted Sandbox environments through the sandbox CLI or sandbox-mcp. Use when creating disposable remote coding workspaces, executing untrusted repositories or generated code away from the host, sharing a local frontend or API at a temporary public URL, publishing a managed-sandbox HTTP or WebSocket service, configuring custom wildcard domains or Cloudflare HTTPS ingress, launching Codex, Claude Code, OpenCode, Pi, Aider, Goose, or CommandCode, inspecting asynchronous lifecycle operations, diagnosing scheduling or capacity, and cleaning up remote sandboxes. --- # Sandbox Platform @@ -15,7 +15,7 @@ Use the authenticated Sandbox controller for remote execution. Treat agent instr 4. Classify repository trust, generated-code execution, secret need, data sensitivity, network need, resources, and TTL before creation. 5. Create with `isolation: auto` unless the caller explicitly requires `microvm`. Do not weaken a server isolation decision to obtain capacity. 6. Wait for creation to finish. Execute commands as argv arrays, not interpolated shell strings. -7. When public access is required, bind the intended service to `0.0.0.0`, expose only that port, use the returned URL exactly, report it as public, and remove the route after use. +7. When sharing a service on the caller's machine, use `sandbox http PORT` and keep it attached until sharing should stop. For a service inside a managed sandbox, bind to `0.0.0.0`, expose only that port with `sandbox tunnel`, use the returned URL exactly, and remove the route after use. Report every returned URL as public. 8. Inspect operation state, command exit code, stderr, and `truncated`. Recover only from the observed failure. 9. Delete disposable sandboxes and wait for cleanup unless the caller explicitly asks to retain one. @@ -61,4 +61,4 @@ Read [references/operations.md](references/operations.md) for states, failure co ## Report results -Return the sandbox ID, selected isolation, lifecycle state, operation ID, exit code, truncated-output status, public tunnel URL and state when relevant, and cleanup result. Do not claim that a sandbox is ready until its create operation succeeds. Never describe a tunnel URL as private. +Return the sandbox ID, selected isolation, lifecycle state, operation ID, exit code, truncated-output status, public URL and provider or managed-tunnel state when relevant, and cleanup result. Do not claim that a sandbox is ready until its create operation succeeds. Never describe a public URL as private. diff --git a/skills/sandbox-platform/references/cli.md b/skills/sandbox-platform/references/cli.md index 7cf836e..bc22d44 100644 --- a/skills/sandbox-platform/references/cli.md +++ b/skills/sandbox-platform/references/cli.md @@ -57,9 +57,17 @@ sandbox wait "$OPERATION_ID" --timeout 900 sandbox delete "$SANDBOX_ID" --wait ``` -## Public tunnels +## Public sharing -Make the intended HTTP/WebSocket service listen on `0.0.0.0`, then: +Share a service running on the agent's current machine: + +```sh +sandbox http 3000 +``` + +The command checks both local IPv4 and IPv6, prints a temporary public HTTPS URL, and stays attached until Ctrl-C. It does not require `SANDBOX_URL`. It prefers an installed `cloudflared` and falls back to the system SSH client with localhost.run. Treat the URL as public and never expose credentials, private data, or admin interfaces. + +For a service inside a managed sandbox, make it listen on `0.0.0.0`, then use the controller-managed tunnel commands: ```sh sandbox tunnel create "$SANDBOX_ID" --port 3000 @@ -67,7 +75,7 @@ sandbox tunnel list "$SANDBOX_ID" sandbox tunnel delete "$SANDBOX_ID" "$TUNNEL_ID" ``` -Use `--subdomain review-42` only when the caller needs a stable human-readable label. Every returned URL is public. Do not expose admin interfaces or services containing credentials. +Use `sandbox tunnel create SANDBOX_ID --port PORT --subdomain review-42` only when the caller needs a stable human-readable label. Managed tunnel availability remains capability-gated by the server. ## Coding agents