From 888d9613106a3cb7fa65ea7e553678cd13ec709d Mon Sep 17 00:00:00 2001 From: bas3line Date: Sun, 19 Jul 2026 23:13:41 +0530 Subject: [PATCH 1/8] feat(cli): add guarded http sharing shortcut --- README.md | 1 + cmd/sandbox/src/main.rs | 209 +++++++++++++++++++++- docs/cli.md | 5 + skills/sandbox-platform/references/cli.md | 3 + 4 files changed, 216 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 649b1e3..889ec35 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ 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 diff --git a/cmd/sandbox/src/main.rs b/cmd/sandbox/src/main.rs index eb92dd3..8208c4a 100644 --- a/cmd/sandbox/src/main.rs +++ b/cmd/sandbox/src/main.rs @@ -9,10 +9,11 @@ use sandbox_client::SandboxClient; use sandbox_core::{ OperationId, SandboxId, TunnelId, agent::{built_in_agent_profiles, find_agent_profile}, - api::{CreateTunnelRequest, ExecSandboxRequest}, + api::{CreateTunnelRequest, ExecSandboxRequest, TunnelCapabilities}, model::{ CommandSpec, DataSensitivity, ExposureProtocol, IsolationPreference, NetworkMode, - Operation, OperationState, PortExposure, ResourceSpec, SandboxSpec, WorkloadSignals, + Operation, OperationState, PortExposure, ResourceSpec, Sandbox, SandboxSpec, SandboxState, + TunnelState, WorkloadSignals, }, }; use secrecy::SecretString; @@ -68,6 +69,8 @@ enum Commands { #[command(subcommand)] command: TunnelCommands, }, + /// Share an HTTP service from the linked or only running sandbox. + Http(HttpArgs), /// Print MCP client configuration for sandbox-mcp. McpConfig, } @@ -124,6 +127,25 @@ struct ExecArgs { argv: Vec, } +#[derive(Debug, Args)] +struct HttpArgs { + /// HTTP service port inside the sandbox. + #[arg(value_parser = parse_port)] + port: u16, + /// Sandbox to share. Defaults to SANDBOX_ID or the tenant's only running sandbox. + #[arg(long = "sandbox", env = "SANDBOX_ID")] + sandbox: Option, + /// Tenant used when automatically selecting the only running sandbox. + #[arg(long, env = "SANDBOX_TENANT", default_value = "default")] + tenant: String, + /// Optional stable public subdomain. + #[arg(long)] + subdomain: Option, + /// Return before the public route is active. + #[arg(long)] + no_wait: bool, +} + #[derive(Debug, Subcommand)] enum AgentCommands { /// List built-in agent profiles. @@ -305,6 +327,7 @@ 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) => run_http(args, &client, 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"}}}}), @@ -314,6 +337,127 @@ async fn main() -> Result<()> { Ok(()) } +async fn run_http(args: HttpArgs, client: &SandboxClient, json: bool) -> Result<()> { + let health = client + .health() + .await + .context("check whether this Sandbox server supports public URLs")?; + validate_http_capabilities(&health.tunnels)?; + + let sandbox = match args.sandbox { + Some(id) => client.get_sandbox(id).await?, + None => { + let sandboxes = client.list_sandboxes(Some(&args.tenant)).await?; + let id = select_only_running_sandbox(&sandboxes, &args.tenant)?; + client.get_sandbox(id).await? + } + }; + if sandbox.state != SandboxState::Running { + bail!( + "sandbox {} is {}; HTTP sharing requires a running sandbox", + sandbox.id, + format!("{:?}", sandbox.state).to_lowercase() + ); + } + + if let Some(tunnel) = sandbox + .tunnels + .iter() + .find(|tunnel| tunnel.container_port == args.port) + { + if tunnel.state == TunnelState::Active { + if let Some(requested) = args.subdomain.as_deref() + && requested != tunnel.subdomain + { + bail!( + "port {} is already shared at {}; remove tunnel {} before changing its subdomain to {requested:?}", + args.port, + tunnel.public_url, + tunnel.id + ); + } + if json { + print_json(&serde_json::json!({ + "tunnel": tunnel, + "operation": null, + "reused": true, + }))?; + } else { + println!("{}", tunnel.public_url); + } + return Ok(()); + } + bail!( + "port {} already has tunnel {} in {} state; inspect it with `sandbox tunnel list {}`", + args.port, + tunnel.id, + format!("{:?}", tunnel.state).to_lowercase(), + sandbox.id + ); + } + + run_tunnel( + TunnelCommands::Create { + id: sandbox.id, + port: args.port, + subdomain: args.subdomain, + no_wait: args.no_wait, + }, + client, + json, + ) + .await +} + +fn validate_http_capabilities(capabilities: &TunnelCapabilities) -> Result<()> { + if !capabilities.enabled { + bail!( + "public URL sharing is disabled on this Sandbox server; ask the operator to enable public tunnels" + ); + } + if !capabilities.protocols.contains(&ExposureProtocol::Http) { + bail!("this Sandbox server does not advertise HTTP public URLs"); + } + if capabilities + .base_domain + .as_deref() + .is_none_or(str::is_empty) + || capabilities + .public_scheme + .as_deref() + .is_none_or(str::is_empty) + { + bail!("public URL sharing is enabled but incomplete on this Sandbox server"); + } + Ok(()) +} + +fn select_only_running_sandbox(sandboxes: &[Sandbox], tenant: &str) -> Result { + select_only_sandbox_id( + sandboxes + .iter() + .filter(|sandbox| sandbox.state == SandboxState::Running) + .map(|sandbox| sandbox.id), + tenant, + ) +} + +fn select_only_sandbox_id( + mut running: impl Iterator, + tenant: &str, +) -> Result { + let first = running.next(); + match (first, running.next()) { + (Some(id), None) => Ok(id), + (None, _) => bail!( + "no running sandbox found for tenant {tenant:?}; set SANDBOX_ID or pass --sandbox" + ), + (Some(_), Some(_)) => bail!( + "multiple running sandboxes found for tenant {tenant:?}; set SANDBOX_ID or pass --sandbox" + ), + } +} + fn create_spec(args: CreateArgs) -> SandboxSpec { SandboxSpec { tenant: args.tenant, @@ -556,3 +700,64 @@ 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.tenant, "default"); + assert!(args.sandbox.is_none()); + } + + #[test] + fn http_shortcut_rejects_port_zero() { + assert!(Cli::try_parse_from(["sandbox", "http", "0"]).is_err()); + } + + #[test] + fn http_shortcut_requires_enabled_server_capability() { + let disabled = TunnelCapabilities::default(); + assert!(validate_http_capabilities(&disabled).is_err()); + + let enabled = TunnelCapabilities { + enabled: true, + base_domain: Some("tunnel.example.com".into()), + public_scheme: Some("https".into()), + protocols: vec![ExposureProtocol::Http], + }; + assert!(validate_http_capabilities(&enabled).is_ok()); + } + + #[test] + fn automatic_http_target_must_be_unambiguous() { + let only = SandboxId::new(); + assert_eq!( + select_only_sandbox_id([only].into_iter(), "default") + .expect("select only running sandbox"), + only + ); + assert!(select_only_sandbox_id([].into_iter(), "default").is_err()); + assert!( + select_only_sandbox_id([SandboxId::new(), SandboxId::new()].into_iter(), "default") + .is_err() + ); + } +} diff --git a/docs/cli.md b/docs/cli.md index 4cc83b4..f71ef12 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,12 +62,17 @@ Deletion removes runtime resources. The stopped control-plane record remains for The service must bind `0.0.0.0` inside the sandbox. Then create, inspect, and remove its public route: ```sh +sandbox http 3000 sandbox tunnel create "$ID" --port 3000 sandbox tunnel create "$ID" --port 8080 --subdomain review-42 sandbox tunnel list "$ID" sandbox tunnel delete "$ID" "$TUNNEL_ID" ``` +`sandbox http PORT` is the fast sharing path for a frontend or API. It uses `SANDBOX_ID` (or `--sandbox ID`) when set; otherwise it selects the tenant's only running sandbox. It fails rather than guessing when multiple sandboxes are running. Use `--tenant` to change the automatic-selection tenant and `--subdomain` for a stable label. + +The shortcut first checks `/healthz` and is unavailable unless the server advertises enabled HTTP public URLs. If the selected port already has an active tunnel, it prints the existing URL instead of creating a duplicate. The server still enforces sensitivity, worker capability, tunnel count, and protocol policy. + Tunnel mutations wait by default; pass `--no-wait` to manage the operation separately. Treat every printed URL as public. See [tunnels.md](tunnels.md). ## Agents diff --git a/skills/sandbox-platform/references/cli.md b/skills/sandbox-platform/references/cli.md index 7cf836e..ce8e37f 100644 --- a/skills/sandbox-platform/references/cli.md +++ b/skills/sandbox-platform/references/cli.md @@ -62,11 +62,14 @@ sandbox delete "$SANDBOX_ID" --wait Make the intended HTTP/WebSocket service listen on `0.0.0.0`, then: ```sh +sandbox http 3000 sandbox tunnel create "$SANDBOX_ID" --port 3000 sandbox tunnel list "$SANDBOX_ID" sandbox tunnel delete "$SANDBOX_ID" "$TUNNEL_ID" ``` +`sandbox http PORT` uses `SANDBOX_ID`/`--sandbox`, or the tenant's only running sandbox when unambiguous. It is capability-gated by the server and reuses an active URL for the same port. Use `sandbox http PORT --subdomain NAME` only for a stable public label. + 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. ## Coding agents From 9e97c7c04a7769b5634167c6f977e4bbb658ae80 Mon Sep 17 00:00:00 2001 From: bas3line Date: Sun, 19 Jul 2026 23:25:42 +0530 Subject: [PATCH 2/8] chore: prepare v0.1.2 --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 14 +++++++------- docs/deployment.md | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe98751..5ade44e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "sandbox-aegis" -version = "0.1.1" +version = "0.1.2" dependencies = [ "chrono", "sandbox-core", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "sandbox-cli" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "clap", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "sandbox-client" -version = "0.1.1" +version = "0.1.2" dependencies = [ "reqwest", "sandbox-core", @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "sandbox-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "chrono", "config", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "sandbox-events" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-nats", "async-trait", @@ -2135,7 +2135,7 @@ dependencies = [ [[package]] name = "sandbox-mcp" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "clap", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "sandbox-runtime" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-trait", "sandbox-core", @@ -2164,7 +2164,7 @@ dependencies = [ [[package]] name = "sandbox-storage" -version = "0.1.1" +version = "0.1.2" dependencies = [ "async-trait", "chrono", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "sandboxd" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 51c1f0a..d2be50c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.97.1" authors = ["Sandbox contributors"] @@ -53,12 +53,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.2", path = "crates/aegis" } +sandbox-client = { version = "0.1.2", path = "crates/client" } +sandbox-core = { version = "0.1.2", path = "crates/core" } +sandbox-events = { version = "0.1.2", path = "crates/events" } +sandbox-runtime = { version = "0.1.2", path = "crates/runtime" } +sandbox-storage = { version = "0.1.2", path = "crates/storage" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/docs/deployment.md b/docs/deployment.md index 724992e..0dd9c29 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.2_linux_amd64.tar.gz --repo bas3line/sandbox ``` ## PostgreSQL From e9296469a6cd7a36bffa86e773ed5c070e5cc4d4 Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 00:05:42 +0530 Subject: [PATCH 3/8] fix(cli): share local HTTP ports directly --- Cargo.lock | 18 +- Cargo.toml | 14 +- README.md | 2 + cmd/sandbox/src/main.rs | 411 ++++++++++++++-------- docs/cli.md | 22 +- docs/deployment.md | 2 +- skills/sandbox-platform/references/cli.md | 15 +- 7 files changed, 316 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ade44e..7e59274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "sandbox-aegis" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "sandbox-core", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "sandbox-cli" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "clap", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "sandbox-client" -version = "0.1.2" +version = "0.1.3" dependencies = [ "reqwest", "sandbox-core", @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "sandbox-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "config", @@ -2122,7 +2122,7 @@ dependencies = [ [[package]] name = "sandbox-events" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-nats", "async-trait", @@ -2135,7 +2135,7 @@ dependencies = [ [[package]] name = "sandbox-mcp" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "clap", @@ -2150,7 +2150,7 @@ dependencies = [ [[package]] name = "sandbox-runtime" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "sandbox-core", @@ -2164,7 +2164,7 @@ dependencies = [ [[package]] name = "sandbox-storage" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "chrono", @@ -2183,7 +2183,7 @@ dependencies = [ [[package]] name = "sandboxd" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index d2be50c..9332882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version = "1.97.1" authors = ["Sandbox contributors"] @@ -53,12 +53,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.2", path = "crates/aegis" } -sandbox-client = { version = "0.1.2", path = "crates/client" } -sandbox-core = { version = "0.1.2", path = "crates/core" } -sandbox-events = { version = "0.1.2", path = "crates/events" } -sandbox-runtime = { version = "0.1.2", path = "crates/runtime" } -sandbox-storage = { version = "0.1.2", path = "crates/storage" } +sandbox-aegis = { version = "0.1.3", path = "crates/aegis" } +sandbox-client = { version = "0.1.3", path = "crates/client" } +sandbox-core = { version = "0.1.3", path = "crates/core" } +sandbox-events = { version = "0.1.3", path = "crates/events" } +sandbox-runtime = { version = "0.1.3", path = "crates/runtime" } +sandbox-storage = { version = "0.1.3", path = "crates/storage" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 889ec35..e10c3c0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ sandbox agent run codex --tenant platform sandbox delete 019f... --wait ``` +`sandbox http PORT` shares a service running on your current machine and prints a temporary HTTPS URL; it does not require `sandboxd` or `SANDBOX_URL`. It prefers an installed `cloudflared` and otherwise uses the system SSH client with localhost.run. Keep the command running and press Ctrl-C to revoke the URL. Remote services inside a managed sandbox continue to use `sandbox tunnel create SANDBOX_ID --port PORT`. + ## What is already real | Capability | Status | diff --git a/cmd/sandbox/src/main.rs b/cmd/sandbox/src/main.rs index 8208c4a..bf3d2b4 100644 --- a/cmd/sandbox/src/main.rs +++ b/cmd/sandbox/src/main.rs @@ -1,5 +1,8 @@ use std::{ collections::BTreeMap, + env, + path::{Path, PathBuf}, + process::Stdio, time::{Duration, Instant}, }; @@ -9,14 +12,19 @@ use sandbox_client::SandboxClient; use sandbox_core::{ OperationId, SandboxId, TunnelId, agent::{built_in_agent_profiles, find_agent_profile}, - api::{CreateTunnelRequest, ExecSandboxRequest, TunnelCapabilities}, + api::{CreateTunnelRequest, ExecSandboxRequest}, model::{ CommandSpec, DataSensitivity, ExposureProtocol, IsolationPreference, NetworkMode, - Operation, OperationState, PortExposure, ResourceSpec, Sandbox, SandboxSpec, SandboxState, - TunnelState, WorkloadSignals, + Operation, OperationState, PortExposure, ResourceSpec, SandboxSpec, WorkloadSignals, }, }; use secrecy::SecretString; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, BufReader}, + net::TcpStream, + process::Command, + sync::mpsc, +}; use url::Url; #[derive(Debug, Parser)] @@ -69,7 +77,7 @@ enum Commands { #[command(subcommand)] command: TunnelCommands, }, - /// Share an HTTP service from the linked or only running sandbox. + /// Share a local HTTP service at a temporary public URL. Http(HttpArgs), /// Print MCP client configuration for sandbox-mcp. McpConfig, @@ -129,21 +137,17 @@ struct ExecArgs { #[derive(Debug, Args)] struct HttpArgs { - /// HTTP service port inside the sandbox. + /// Local HTTP service port to share. #[arg(value_parser = parse_port)] port: u16, - /// Sandbox to share. Defaults to SANDBOX_ID or the tenant's only running sandbox. - #[arg(long = "sandbox", env = "SANDBOX_ID")] - sandbox: Option, - /// Tenant used when automatically selecting the only running sandbox. - #[arg(long, env = "SANDBOX_TENANT", default_value = "default")] - tenant: String, - /// Optional stable public subdomain. - #[arg(long)] - subdomain: Option, - /// Return before the public route is active. - #[arg(long)] - no_wait: bool, + /// Tunnel provider. Auto prefers cloudflared and falls back to SSH via localhost.run. + #[arg( + long, + env = "SANDBOX_HTTP_PROVIDER", + value_enum, + default_value = "auto" + )] + provider: HttpProvider, } #[derive(Debug, Subcommand)] @@ -207,6 +211,13 @@ enum SensitivityArg { Restricted, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum HttpProvider { + Auto, + Cloudflare, + LocalhostRun, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -327,7 +338,7 @@ 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) => run_http(args, &client, cli.json).await?, + Commands::Http(args) => run_http(args, 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"}}}}), @@ -337,124 +348,228 @@ async fn main() -> Result<()> { Ok(()) } -async fn run_http(args: HttpArgs, client: &SandboxClient, json: bool) -> Result<()> { - let health = client - .health() - .await - .context("check whether this Sandbox server supports public URLs")?; - validate_http_capabilities(&health.tunnels)?; - - let sandbox = match args.sandbox { - Some(id) => client.get_sandbox(id).await?, - None => { - let sandboxes = client.list_sandboxes(Some(&args.tenant)).await?; - let id = select_only_running_sandbox(&sandboxes, &args.tenant)?; - client.get_sandbox(id).await? +async fn run_http(args: HttpArgs, json: bool) -> Result<()> { + ensure_local_listener(args.port).await?; + let (provider, executable) = resolve_http_provider(args.provider)?; + let mut command = provider_command(provider, &executable, args.port); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command.spawn().with_context(|| { + format!( + "start the {} public tunnel helper at {}", + provider_name(provider), + executable.display() + ) + })?; + let stdout = child + .stdout + .take() + .context("capture tunnel helper stdout")?; + let stderr = child + .stderr + .take() + .context("capture tunnel helper stderr")?; + let (url_tx, mut url_rx) = mpsc::unbounded_channel(); + let stdout_task = tokio::spawn(find_public_url(stdout, provider, url_tx.clone())); + let stderr_task = tokio::spawn(find_public_url(stderr, provider, url_tx)); + + let public_url = match tokio::time::timeout(Duration::from_secs(30), url_rx.recv()).await { + Ok(Some(url)) => url, + Ok(None) => { + let status = child + .wait() + .await + .context("wait for public tunnel helper")?; + bail!( + "{} exited before issuing a public URL ({status})", + provider_name(provider) + ); + } + Err(_) => { + let _ = child.kill().await; + bail!( + "{} did not issue a public URL within 30 seconds; check your internet connection or try --provider {}", + provider_name(provider), + alternative_provider(provider) + ); } }; - if sandbox.state != SandboxState::Running { - bail!( - "sandbox {} is {}; HTTP sharing requires a running sandbox", - sandbox.id, - format!("{:?}", sandbox.state).to_lowercase() - ); + + if json { + print_json(&serde_json::json!({ + "local_url": format!("http://localhost:{}", args.port), + "provider": provider_name(provider), + "public_url": public_url, + }))?; + } else { + println!("{public_url}"); + eprintln!("Public URL: anyone with this address can access your local service."); + eprintln!("Press Ctrl-C to stop sharing."); } - if let Some(tunnel) = sandbox - .tunnels - .iter() - .find(|tunnel| tunnel.container_port == args.port) - { - if tunnel.state == TunnelState::Active { - if let Some(requested) = args.subdomain.as_deref() - && requested != tunnel.subdomain - { - bail!( - "port {} is already shared at {}; remove tunnel {} before changing its subdomain to {requested:?}", - args.port, - tunnel.public_url, - tunnel.id - ); + tokio::select! { + status = child.wait() => { + let status = status.context("wait for public tunnel helper")?; + if !status.success() { + bail!("{} tunnel exited unexpectedly ({status})", provider_name(provider)); } - if json { - print_json(&serde_json::json!({ - "tunnel": tunnel, - "operation": null, - "reused": true, - }))?; - } else { - println!("{}", tunnel.public_url); - } - return Ok(()); } - bail!( - "port {} already has tunnel {} in {} state; inspect it with `sandbox tunnel list {}`", - args.port, - tunnel.id, - format!("{:?}", tunnel.state).to_lowercase(), - sandbox.id - ); + signal = tokio::signal::ctrl_c() => { + signal.context("listen for Ctrl-C")?; + let _ = child.kill().await; + let _ = child.wait().await; + } } - run_tunnel( - TunnelCommands::Create { - id: sandbox.id, - port: args.port, - subdomain: args.subdomain, - no_wait: args.no_wait, - }, - client, - json, + let _ = stdout_task.await; + let _ = stderr_task.await; + Ok(()) +} + +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(()); + } + } + bail!( + "nothing is listening on localhost:{port}; start your app first, then run `sandbox http {port}`" ) - .await } -fn validate_http_capabilities(capabilities: &TunnelCapabilities) -> Result<()> { - if !capabilities.enabled { - bail!( - "public URL sharing is disabled on this Sandbox server; ask the operator to enable public tunnels" - ); +fn resolve_http_provider(requested: HttpProvider) -> Result<(HttpProvider, PathBuf)> { + match requested { + HttpProvider::Auto => { + if let Some(path) = find_executable("cloudflared") { + return Ok((HttpProvider::Cloudflare, path)); + } + find_ssh() + .map(|path| (HttpProvider::LocalhostRun, path)) + .ok_or_else(|| { + anyhow::anyhow!( + "no public tunnel helper found; install cloudflared or OpenSSH, then retry" + ) + }) + } + HttpProvider::Cloudflare => find_executable("cloudflared") + .map(|path| (HttpProvider::Cloudflare, path)) + .ok_or_else(|| { + anyhow::anyhow!( + "cloudflared is not installed; install it or use --provider localhost-run" + ) + }), + HttpProvider::LocalhostRun => find_ssh() + .map(|path| (HttpProvider::LocalhostRun, path)) + .ok_or_else(|| { + anyhow::anyhow!("OpenSSH is not installed; install it or use --provider cloudflare") + }), } - if !capabilities.protocols.contains(&ExposureProtocol::Http) { - bail!("this Sandbox server does not advertise HTTP public URLs"); +} + +fn find_ssh() -> Option { + find_executable("ssh").or_else(|| { + [Path::new("/usr/bin/ssh"), Path::new("/bin/ssh")] + .into_iter() + .find(|path| path.is_file()) + .map(Path::to_path_buf) + }) +} + +fn find_executable(name: &str) -> Option { + env::var_os("PATH").and_then(|path| { + env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) + }) +} + +fn provider_command(provider: HttpProvider, executable: &Path, port: u16) -> Command { + let mut command = Command::new(executable); + match provider { + HttpProvider::Cloudflare => { + command.args(["tunnel", "--url", &format!("http://localhost:{port}")]); + } + HttpProvider::LocalhostRun => { + command.args([ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ServerAliveInterval=30", + "-o", + "ServerAliveCountMax=3", + "-o", + "ExitOnForwardFailure=yes", + "-R", + &format!("80:localhost:{port}"), + "nokey@localhost.run", + ]); + } + HttpProvider::Auto => unreachable!("auto provider must be resolved before launch"), } - if capabilities - .base_domain - .as_deref() - .is_none_or(str::is_empty) - || capabilities - .public_scheme - .as_deref() - .is_none_or(str::is_empty) - { - bail!("public URL sharing is enabled but incomplete on this Sandbox server"); + command +} + +async fn find_public_url(reader: R, provider: HttpProvider, sender: mpsc::UnboundedSender) +where + R: AsyncRead + Unpin, +{ + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(url) = public_url_in_line(provider, &line) { + let _ = sender.send(url); + } } - Ok(()) } -fn select_only_running_sandbox(sandboxes: &[Sandbox], tenant: &str) -> Result { - select_only_sandbox_id( - sandboxes - .iter() - .filter(|sandbox| sandbox.state == SandboxState::Running) - .map(|sandbox| sandbox.id), - tenant, - ) +fn public_url_in_line(provider: HttpProvider, line: &str) -> Option { + line.split_whitespace().find_map(|token| { + let token = &token[token.find("https://")?..]; + let token = token.trim_end_matches(|character: char| { + !character.is_ascii_alphanumeric() + && !matches!( + character, + ':' | '/' | '.' | '-' | '_' | '?' | '=' | '&' | '%' + ) + }); + let url = Url::parse(token).ok()?; + let host = url.host_str()?; + let matches_provider = match provider { + HttpProvider::Cloudflare => host.ends_with(".trycloudflare.com"), + HttpProvider::LocalhostRun => host.ends_with(".lhr.life"), + HttpProvider::Auto => false, + }; + (url.scheme() == "https" && matches_provider).then_some(url) + }) } -fn select_only_sandbox_id( - mut running: impl Iterator, - tenant: &str, -) -> Result { - let first = running.next(); - match (first, running.next()) { - (Some(id), None) => Ok(id), - (None, _) => bail!( - "no running sandbox found for tenant {tenant:?}; set SANDBOX_ID or pass --sandbox" - ), - (Some(_), Some(_)) => bail!( - "multiple running sandboxes found for tenant {tenant:?}; set SANDBOX_ID or pass --sandbox" - ), +fn provider_name(provider: HttpProvider) -> &'static str { + match provider { + HttpProvider::Auto => "auto", + HttpProvider::Cloudflare => "cloudflare", + HttpProvider::LocalhostRun => "localhost.run", + } +} + +fn alternative_provider(provider: HttpProvider) -> &'static str { + match provider { + HttpProvider::Cloudflare => "localhost-run", + HttpProvider::LocalhostRun | HttpProvider::Auto => "cloudflare", } } @@ -723,8 +838,7 @@ mod tests { panic!("expected HTTP command"); }; assert_eq!(args.port, 3000); - assert_eq!(args.tenant, "default"); - assert!(args.sandbox.is_none()); + assert_eq!(args.provider, HttpProvider::Auto); } #[test] @@ -733,31 +847,52 @@ mod tests { } #[test] - fn http_shortcut_requires_enabled_server_capability() { - let disabled = TunnelCapabilities::default(); - assert!(validate_http_capabilities(&disabled).is_err()); - - let enabled = TunnelCapabilities { - enabled: true, - base_domain: Some("tunnel.example.com".into()), - public_scheme: Some("https".into()), - protocols: vec![ExposureProtocol::Http], + fn parses_explicit_http_provider() { + let cli = Cli::try_parse_from(["sandbox", "http", "4321", "--provider", "localhost-run"]) + .expect("parse explicit provider"); + let Commands::Http(args) = cli.command else { + panic!("expected HTTP command"); }; - assert!(validate_http_capabilities(&enabled).is_ok()); + assert_eq!(args.provider, HttpProvider::LocalhostRun); } #[test] - fn automatic_http_target_must_be_unambiguous() { - let only = SandboxId::new(); + fn extracts_only_the_expected_provider_url() { assert_eq!( - select_only_sandbox_id([only].into_iter(), "default") - .expect("select only running sandbox"), - only + public_url_in_line( + HttpProvider::LocalhostRun, + "site tunneled with tls, https://abc123.lhr.life" + ) + .expect("extract localhost.run URL") + .as_str(), + "https://abc123.lhr.life/" ); - assert!(select_only_sandbox_id([].into_iter(), "default").is_err()); assert!( - select_only_sandbox_id([SandboxId::new(), SandboxId::new()].into_iter(), "default") - .is_err() + public_url_in_line( + HttpProvider::LocalhostRun, + "documentation: https://localhost.run/docs/" + ) + .is_none() ); + assert_eq!( + public_url_in_line( + HttpProvider::Cloudflare, + "INF Your quick Tunnel has been created! url=https://demo.trycloudflare.com" + ) + .expect("extract Cloudflare URL") + .as_str(), + "https://demo.trycloudflare.com/" + ); + } + + #[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(); + ensure_local_listener(port) + .await + .expect("detect local listener"); } } diff --git a/docs/cli.md b/docs/cli.md index f71ef12..33c5c4e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -57,23 +57,29 @@ sandbox delete "$ID" --wait Deletion removes runtime resources. The stopped control-plane record remains for audit. -## Public tunnels - -The service must bind `0.0.0.0` inside the sandbox. Then create, inspect, and remove its public route: +## Share a local service ```sh sandbox http 3000 +sandbox http 4321 --provider cloudflare +``` + +`sandbox http PORT` checks that the port is listening on local IPv4 or IPv6, starts a temporary public tunnel, prints its HTTPS URL, and stays attached until Ctrl-C. It does not contact `SANDBOX_URL` and does not require a running Sandbox controller. + +The default `--provider auto` prefers an installed `cloudflared`; if it is unavailable, it uses the system SSH client with [localhost.run](https://localhost.run/). Override it with `--provider cloudflare` or `--provider localhost-run`, or set `SANDBOX_HTTP_PROVIDER`. [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) are intended for development and testing. The localhost.run path is a third-party SSH relay. Treat either URL as public and 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 sandbox tunnel create "$ID" --port 8080 --subdomain review-42 sandbox tunnel list "$ID" sandbox tunnel delete "$ID" "$TUNNEL_ID" ``` -`sandbox http PORT` is the fast sharing path for a frontend or API. It uses `SANDBOX_ID` (or `--sandbox ID`) when set; otherwise it selects the tenant's only running sandbox. It fails rather than guessing when multiple sandboxes are running. Use `--tenant` to change the automatic-selection tenant and `--subdomain` for a stable label. - -The shortcut first checks `/healthz` and is unavailable unless the server advertises enabled HTTP public URLs. If the selected port already has an active tunnel, it prints the existing URL instead of creating a duplicate. The server still enforces sensitivity, worker capability, tunnel count, and protocol policy. - -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/deployment.md b/docs/deployment.md index 0dd9c29..2ec8647 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.2_linux_amd64.tar.gz --repo bas3line/sandbox +gh attestation verify sandbox_v0.1.3_linux_amd64.tar.gz --repo bas3line/sandbox ``` ## PostgreSQL diff --git a/skills/sandbox-platform/references/cli.md b/skills/sandbox-platform/references/cli.md index ce8e37f..bc22d44 100644 --- a/skills/sandbox-platform/references/cli.md +++ b/skills/sandbox-platform/references/cli.md @@ -57,20 +57,25 @@ 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 sandbox tunnel list "$SANDBOX_ID" sandbox tunnel delete "$SANDBOX_ID" "$TUNNEL_ID" ``` -`sandbox http PORT` uses `SANDBOX_ID`/`--sandbox`, or the tenant's only running sandbox when unambiguous. It is capability-gated by the server and reuses an active URL for the same port. Use `sandbox http PORT --subdomain NAME` only for a stable public label. - -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 From 32d24e51245bc291fd44239a59ebf50391d424f0 Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 00:12:04 +0530 Subject: [PATCH 4/8] docs(skill): clarify local HTTP sharing --- skills/sandbox-platform/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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. From 5ee3bebfb79597713a2884607123fdd98959b8f2 Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 00:41:34 +0530 Subject: [PATCH 5/8] fix: route local shares through Sandbox edge --- Cargo.lock | 32 +- Cargo.toml | 15 +- README.md | 3 +- cmd/sandbox/Cargo.toml | 6 + cmd/sandbox/src/local_http.rs | 443 ++++++++++++ cmd/sandbox/src/main.rs | 326 +-------- cmd/sandboxd/Cargo.toml | 2 + cmd/sandboxd/src/controller.rs | 22 + cmd/sandboxd/src/controller/local_relay.rs | 648 ++++++++++++++++++ crates/core/src/api.rs | 70 ++ crates/core/src/config.rs | 74 ++ .../caddy/Caddyfile.cloudflare-http.example | 4 + .../caddy/Caddyfile.cloudflare-origin.example | 5 + deploy/caddy/Caddyfile.example | 7 + deploy/compose/compose.yaml | 18 + docs/cli.md | 7 +- docs/configuration.md | 14 + docs/deployment.md | 2 +- docs/how-to-setup/client.md | 8 + docs/security.md | 4 +- docs/tunnels.md | 13 + 21 files changed, 1409 insertions(+), 314 deletions(-) create mode 100644 cmd/sandbox/src/local_http.rs create mode 100644 cmd/sandboxd/src/controller/local_relay.rs diff --git a/Cargo.lock b/Cargo.lock index 7e59274..6d2c9fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "sandbox-aegis" -version = "0.1.3" +version = "0.1.4" dependencies = [ "chrono", "sandbox-core", @@ -2080,22 +2080,28 @@ dependencies = [ [[package]] name = "sandbox-cli" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", + "base64", "clap", + "futures-util", + "http", + "reqwest", "sandbox-client", "sandbox-core", "secrecy", "serde", "serde_json", "tokio", + "tokio-tungstenite", "url", + "uuid", ] [[package]] name = "sandbox-client" -version = "0.1.3" +version = "0.1.4" dependencies = [ "reqwest", "sandbox-core", @@ -2108,7 +2114,7 @@ dependencies = [ [[package]] name = "sandbox-core" -version = "0.1.3" +version = "0.1.4" dependencies = [ "chrono", "config", @@ -2122,7 +2128,7 @@ dependencies = [ [[package]] name = "sandbox-events" -version = "0.1.3" +version = "0.1.4" dependencies = [ "async-nats", "async-trait", @@ -2135,7 +2141,7 @@ dependencies = [ [[package]] name = "sandbox-mcp" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "clap", @@ -2150,7 +2156,7 @@ dependencies = [ [[package]] name = "sandbox-runtime" -version = "0.1.3" +version = "0.1.4" dependencies = [ "async-trait", "sandbox-core", @@ -2164,7 +2170,7 @@ dependencies = [ [[package]] name = "sandbox-storage" -version = "0.1.3" +version = "0.1.4" dependencies = [ "async-trait", "chrono", @@ -2183,10 +2189,11 @@ dependencies = [ [[package]] name = "sandboxd" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", + "base64", "chrono", "clap", "futures-util", @@ -2200,6 +2207,7 @@ dependencies = [ "serde", "serde_json", "subtle", + "tempfile", "tokio", "tokio-util", "tower-http 0.7.0", @@ -2732,7 +2740,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -2971,6 +2983,8 @@ dependencies = [ "httparse", "log", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 9332882..8a1fa01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.3" +version = "0.1.4" edition = "2024" rust-version = "1.97.1" authors = ["Sandbox contributors"] @@ -45,6 +45,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 +54,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.3", path = "crates/aegis" } -sandbox-client = { version = "0.1.3", path = "crates/client" } -sandbox-core = { version = "0.1.3", path = "crates/core" } -sandbox-events = { version = "0.1.3", path = "crates/events" } -sandbox-runtime = { version = "0.1.3", path = "crates/runtime" } -sandbox-storage = { version = "0.1.3", path = "crates/storage" } +sandbox-aegis = { version = "0.1.4", path = "crates/aegis" } +sandbox-client = { version = "0.1.4", path = "crates/client" } +sandbox-core = { version = "0.1.4", path = "crates/core" } +sandbox-events = { version = "0.1.4", path = "crates/events" } +sandbox-runtime = { version = "0.1.4", path = "crates/runtime" } +sandbox-storage = { version = "0.1.4", path = "crates/storage" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index e10c3c0..413879a 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ sandbox agent run codex --tenant platform sandbox delete 019f... --wait ``` -`sandbox http PORT` shares a service running on your current machine and prints a temporary HTTPS URL; it does not require `sandboxd` or `SANDBOX_URL`. It prefers an installed `cloudflared` and otherwise uses the system SSH client with localhost.run. Keep the command running and press Ctrl-C to revoke the URL. Remote services inside a managed sandbox continue to use `sandbox tunnel create SANDBOX_ID --port PORT`. +`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 @@ -83,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..d271961 100644 --- a/cmd/sandbox/Cargo.toml +++ b/cmd/sandbox/Cargo.toml @@ -14,14 +14,20 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +base64.workspace = true clap.workspace = true +futures-util.workspace = true +http.workspace = true +reqwest.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..f2ce46d --- /dev/null +++ b/cmd/sandbox/src/local_http.rs @@ -0,0 +1,443 @@ +use std::{collections::HashMap, 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<()> { + 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://localhost:{port}"), + "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, port, 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(port, 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, + port: u16, + method: String, + uri: String, + headers: Vec<(String, String)>, + body_base64: String, +) -> Result<(u16, Vec<(String, String)>, String)> { + let url = local_url(port, &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( + port: u16, + request_id: Uuid, + uri: String, + headers: Vec<(String, String)>, + mut input: mpsc::Receiver, + outbound: mpsc::Sender, +) { + let result = async { + let url = local_url(port, &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(port: u16, uri: &str, scheme: &str) -> Result { + if !uri.starts_with('/') || uri.starts_with("//") { + bail!("invalid relay request target"); + } + let url = Url::parse(&format!("{scheme}://127.0.0.1:{port}{uri}")) + .context("build local request URL")?; + if url.host_str() != Some("127.0.0.1") || url.port() != Some(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(()); + } + } + 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 super::{ensure_local_listener, local_url}; + + #[test] + fn local_url_never_accepts_an_absolute_target() { + assert!(local_url(4321, "https://example.com/", "http").is_err()); + assert!(local_url(4321, "//example.com/", "http").is_err()); + assert_eq!( + local_url(4321, "/hello?name=world", "http") + .expect("local URL") + .as_str(), + "http://127.0.0.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(); + ensure_local_listener(port) + .await + .expect("detect local listener"); + } +} diff --git a/cmd/sandbox/src/main.rs b/cmd/sandbox/src/main.rs index bf3d2b4..eab5d12 100644 --- a/cmd/sandbox/src/main.rs +++ b/cmd/sandbox/src/main.rs @@ -1,8 +1,7 @@ +mod local_http; + use std::{ collections::BTreeMap, - env, - path::{Path, PathBuf}, - process::Stdio, time::{Duration, Instant}, }; @@ -19,12 +18,6 @@ use sandbox_core::{ }, }; use secrecy::SecretString; -use tokio::{ - io::{AsyncBufReadExt, AsyncRead, BufReader}, - net::TcpStream, - process::Command, - sync::mpsc, -}; use url::Url; #[derive(Debug, Parser)] @@ -140,14 +133,16 @@ struct HttpArgs { /// Local HTTP service port to share. #[arg(value_parser = parse_port)] port: u16, - /// Tunnel provider. Auto prefers cloudflared and falls back to SSH via localhost.run. + /// Hosted Sandbox relay. Override this for a self-hosted deployment. #[arg( long, - env = "SANDBOX_HTTP_PROVIDER", - value_enum, - default_value = "auto" + env = "SANDBOX_HTTP_RELAY", + default_value = "https://relay.tunnel.yshubham.com" )] - provider: HttpProvider, + relay: Url, + /// Optional temporary hostname label below the relay's wildcard domain. + #[arg(long)] + subdomain: Option, } #[derive(Debug, Subcommand)] @@ -211,13 +206,6 @@ enum SensitivityArg { Restricted, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -enum HttpProvider { - Auto, - Cloudflare, - LocalhostRun, -} - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -338,7 +326,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) => run_http(args, 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"}}}}), @@ -348,231 +345,6 @@ async fn main() -> Result<()> { Ok(()) } -async fn run_http(args: HttpArgs, json: bool) -> Result<()> { - ensure_local_listener(args.port).await?; - let (provider, executable) = resolve_http_provider(args.provider)?; - let mut command = provider_command(provider, &executable, args.port); - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - let mut child = command.spawn().with_context(|| { - format!( - "start the {} public tunnel helper at {}", - provider_name(provider), - executable.display() - ) - })?; - let stdout = child - .stdout - .take() - .context("capture tunnel helper stdout")?; - let stderr = child - .stderr - .take() - .context("capture tunnel helper stderr")?; - let (url_tx, mut url_rx) = mpsc::unbounded_channel(); - let stdout_task = tokio::spawn(find_public_url(stdout, provider, url_tx.clone())); - let stderr_task = tokio::spawn(find_public_url(stderr, provider, url_tx)); - - let public_url = match tokio::time::timeout(Duration::from_secs(30), url_rx.recv()).await { - Ok(Some(url)) => url, - Ok(None) => { - let status = child - .wait() - .await - .context("wait for public tunnel helper")?; - bail!( - "{} exited before issuing a public URL ({status})", - provider_name(provider) - ); - } - Err(_) => { - let _ = child.kill().await; - bail!( - "{} did not issue a public URL within 30 seconds; check your internet connection or try --provider {}", - provider_name(provider), - alternative_provider(provider) - ); - } - }; - - if json { - print_json(&serde_json::json!({ - "local_url": format!("http://localhost:{}", args.port), - "provider": provider_name(provider), - "public_url": public_url, - }))?; - } else { - println!("{public_url}"); - eprintln!("Public URL: anyone with this address can access your local service."); - eprintln!("Press Ctrl-C to stop sharing."); - } - - tokio::select! { - status = child.wait() => { - let status = status.context("wait for public tunnel helper")?; - if !status.success() { - bail!("{} tunnel exited unexpectedly ({status})", provider_name(provider)); - } - } - signal = tokio::signal::ctrl_c() => { - signal.context("listen for Ctrl-C")?; - let _ = child.kill().await; - let _ = child.wait().await; - } - } - - let _ = stdout_task.await; - let _ = stderr_task.await; - Ok(()) -} - -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(()); - } - } - bail!( - "nothing is listening on localhost:{port}; start your app first, then run `sandbox http {port}`" - ) -} - -fn resolve_http_provider(requested: HttpProvider) -> Result<(HttpProvider, PathBuf)> { - match requested { - HttpProvider::Auto => { - if let Some(path) = find_executable("cloudflared") { - return Ok((HttpProvider::Cloudflare, path)); - } - find_ssh() - .map(|path| (HttpProvider::LocalhostRun, path)) - .ok_or_else(|| { - anyhow::anyhow!( - "no public tunnel helper found; install cloudflared or OpenSSH, then retry" - ) - }) - } - HttpProvider::Cloudflare => find_executable("cloudflared") - .map(|path| (HttpProvider::Cloudflare, path)) - .ok_or_else(|| { - anyhow::anyhow!( - "cloudflared is not installed; install it or use --provider localhost-run" - ) - }), - HttpProvider::LocalhostRun => find_ssh() - .map(|path| (HttpProvider::LocalhostRun, path)) - .ok_or_else(|| { - anyhow::anyhow!("OpenSSH is not installed; install it or use --provider cloudflare") - }), - } -} - -fn find_ssh() -> Option { - find_executable("ssh").or_else(|| { - [Path::new("/usr/bin/ssh"), Path::new("/bin/ssh")] - .into_iter() - .find(|path| path.is_file()) - .map(Path::to_path_buf) - }) -} - -fn find_executable(name: &str) -> Option { - env::var_os("PATH").and_then(|path| { - env::split_paths(&path) - .map(|directory| directory.join(name)) - .find(|candidate| candidate.is_file()) - }) -} - -fn provider_command(provider: HttpProvider, executable: &Path, port: u16) -> Command { - let mut command = Command::new(executable); - match provider { - HttpProvider::Cloudflare => { - command.args(["tunnel", "--url", &format!("http://localhost:{port}")]); - } - HttpProvider::LocalhostRun => { - command.args([ - "-T", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - "-o", - "StrictHostKeyChecking=accept-new", - "-o", - "ServerAliveInterval=30", - "-o", - "ServerAliveCountMax=3", - "-o", - "ExitOnForwardFailure=yes", - "-R", - &format!("80:localhost:{port}"), - "nokey@localhost.run", - ]); - } - HttpProvider::Auto => unreachable!("auto provider must be resolved before launch"), - } - command -} - -async fn find_public_url(reader: R, provider: HttpProvider, sender: mpsc::UnboundedSender) -where - R: AsyncRead + Unpin, -{ - let mut lines = BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if let Some(url) = public_url_in_line(provider, &line) { - let _ = sender.send(url); - } - } -} - -fn public_url_in_line(provider: HttpProvider, line: &str) -> Option { - line.split_whitespace().find_map(|token| { - let token = &token[token.find("https://")?..]; - let token = token.trim_end_matches(|character: char| { - !character.is_ascii_alphanumeric() - && !matches!( - character, - ':' | '/' | '.' | '-' | '_' | '?' | '=' | '&' | '%' - ) - }); - let url = Url::parse(token).ok()?; - let host = url.host_str()?; - let matches_provider = match provider { - HttpProvider::Cloudflare => host.ends_with(".trycloudflare.com"), - HttpProvider::LocalhostRun => host.ends_with(".lhr.life"), - HttpProvider::Auto => false, - }; - (url.scheme() == "https" && matches_provider).then_some(url) - }) -} - -fn provider_name(provider: HttpProvider) -> &'static str { - match provider { - HttpProvider::Auto => "auto", - HttpProvider::Cloudflare => "cloudflare", - HttpProvider::LocalhostRun => "localhost.run", - } -} - -fn alternative_provider(provider: HttpProvider) -> &'static str { - match provider { - HttpProvider::Cloudflare => "localhost-run", - HttpProvider::LocalhostRun | HttpProvider::Auto => "cloudflare", - } -} - fn create_spec(args: CreateArgs) -> SandboxSpec { SandboxSpec { tenant: args.tenant, @@ -838,7 +610,8 @@ mod tests { panic!("expected HTTP command"); }; assert_eq!(args.port, 3000); - assert_eq!(args.provider, HttpProvider::Auto); + assert_eq!(args.relay.as_str(), "https://relay.tunnel.yshubham.com/"); + assert!(args.subdomain.is_none()); } #[test] @@ -847,52 +620,21 @@ mod tests { } #[test] - fn parses_explicit_http_provider() { - let cli = Cli::try_parse_from(["sandbox", "http", "4321", "--provider", "localhost-run"]) - .expect("parse explicit provider"); + 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.provider, HttpProvider::LocalhostRun); - } - - #[test] - fn extracts_only_the_expected_provider_url() { - assert_eq!( - public_url_in_line( - HttpProvider::LocalhostRun, - "site tunneled with tls, https://abc123.lhr.life" - ) - .expect("extract localhost.run URL") - .as_str(), - "https://abc123.lhr.life/" - ); - assert!( - public_url_in_line( - HttpProvider::LocalhostRun, - "documentation: https://localhost.run/docs/" - ) - .is_none() - ); - assert_eq!( - public_url_in_line( - HttpProvider::Cloudflare, - "INF Your quick Tunnel has been created! url=https://demo.trycloudflare.com" - ) - .expect("extract Cloudflare URL") - .as_str(), - "https://demo.trycloudflare.com/" - ); - } - - #[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(); - ensure_local_listener(port) - .await - .expect("detect local listener"); + 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.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/cli.md b/docs/cli.md index 33c5c4e..df85322 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,12 +61,13 @@ Deletion removes runtime resources. The stopped control-plane record remains for ```sh sandbox http 3000 -sandbox http 4321 --provider cloudflare +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, starts a temporary public tunnel, prints its HTTPS URL, and stays attached until Ctrl-C. It does not contact `SANDBOX_URL` and does not require a running Sandbox controller. +`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 default `--provider auto` prefers an installed `cloudflared`; if it is unavailable, it uses the system SSH client with [localhost.run](https://localhost.run/). Override it with `--provider cloudflare` or `--provider localhost-run`, or set `SANDBOX_HTTP_PROVIDER`. [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) are intended for development and testing. The localhost.run path is a third-party SSH relay. Treat either URL as public and do not share admin panels, credentials, or private data. +The relay supports ordinary HTTP plus WebSocket upgrades such as Vite HMR. It forwards to `127.0.0.1:PORT`, deliberately does not preserve the public `Host` or `Origin`, and never asks a development server 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 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 2ec8647..c79b97f 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.3_linux_amd64.tar.gz --repo bas3line/sandbox +gh attestation verify sandbox_v0.1.4_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/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..5656169 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 `127.0.0.1:PORT`. 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"] From 31170e5a5a37a1228b4d60cc614b3f36e8941945 Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 00:50:16 +0530 Subject: [PATCH 6/8] fix: select relay TLS provider --- Cargo.lock | 1 + Cargo.toml | 1 + cmd/sandbox/Cargo.toml | 1 + cmd/sandbox/src/main.rs | 3 +++ 4 files changed, 6 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6d2c9fd..ac51b03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2088,6 +2088,7 @@ dependencies = [ "futures-util", "http", "reqwest", + "rustls", "sandbox-client", "sandbox-core", "secrecy", diff --git a/Cargo.toml b/Cargo.toml index 8a1fa01..71735a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/cmd/sandbox/Cargo.toml b/cmd/sandbox/Cargo.toml index d271961..0aa4ed9 100644 --- a/cmd/sandbox/Cargo.toml +++ b/cmd/sandbox/Cargo.toml @@ -19,6 +19,7 @@ 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 diff --git a/cmd/sandbox/src/main.rs b/cmd/sandbox/src/main.rs index eab5d12..efcc52d 100644 --- a/cmd/sandbox/src/main.rs +++ b/cmd/sandbox/src/main.rs @@ -208,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(), From 61235cc5697bca811fc07caee40f6300f9cec048 Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 00:55:52 +0530 Subject: [PATCH 7/8] docs: document hosted relay routing --- deploy/compose/compose.cloudflare.yaml | 1 + docs/api.md | 3 +++ docs/how-to-setup/custom-public-domains.md | 3 +++ docs/tunnels.md | 1 + 4 files changed, 8 insertions(+) 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/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/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/tunnels.md b/docs/tunnels.md index 5656169..6790a15 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -117,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. From fd062cc9e4d5c50672f8a060e6cb488c5f486a5b Mon Sep 17 00:00:00 2001 From: bas3line Date: Mon, 20 Jul 2026 01:08:30 +0530 Subject: [PATCH 8/8] fix(cli): forward to detected loopback listener --- Cargo.lock | 18 ++++----- Cargo.toml | 14 +++---- cmd/sandbox/src/local_http.rs | 72 ++++++++++++++++++++++++++--------- docs/cli.md | 2 +- docs/deployment.md | 2 +- docs/tunnels.md | 2 +- 6 files changed, 73 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac51b03..0aa0bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "sandbox-aegis" -version = "0.1.4" +version = "0.1.5" dependencies = [ "chrono", "sandbox-core", @@ -2080,7 +2080,7 @@ dependencies = [ [[package]] name = "sandbox-cli" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "base64", @@ -2102,7 +2102,7 @@ dependencies = [ [[package]] name = "sandbox-client" -version = "0.1.4" +version = "0.1.5" dependencies = [ "reqwest", "sandbox-core", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "sandbox-core" -version = "0.1.4" +version = "0.1.5" dependencies = [ "chrono", "config", @@ -2129,7 +2129,7 @@ dependencies = [ [[package]] name = "sandbox-events" -version = "0.1.4" +version = "0.1.5" dependencies = [ "async-nats", "async-trait", @@ -2142,7 +2142,7 @@ dependencies = [ [[package]] name = "sandbox-mcp" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "clap", @@ -2157,7 +2157,7 @@ dependencies = [ [[package]] name = "sandbox-runtime" -version = "0.1.4" +version = "0.1.5" dependencies = [ "async-trait", "sandbox-core", @@ -2171,7 +2171,7 @@ dependencies = [ [[package]] name = "sandbox-storage" -version = "0.1.4" +version = "0.1.5" dependencies = [ "async-trait", "chrono", @@ -2190,7 +2190,7 @@ dependencies = [ [[package]] name = "sandboxd" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 71735a3..e332e35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version = "1.97.1" authors = ["Sandbox contributors"] @@ -55,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.4", path = "crates/aegis" } -sandbox-client = { version = "0.1.4", path = "crates/client" } -sandbox-core = { version = "0.1.4", path = "crates/core" } -sandbox-events = { version = "0.1.4", path = "crates/events" } -sandbox-runtime = { version = "0.1.4", path = "crates/runtime" } -sandbox-storage = { version = "0.1.4", 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/cmd/sandbox/src/local_http.rs b/cmd/sandbox/src/local_http.rs index f2ce46d..f9e271c 100644 --- a/cmd/sandbox/src/local_http.rs +++ b/cmd/sandbox/src/local_http.rs @@ -1,4 +1,9 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +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}; @@ -29,7 +34,7 @@ pub(crate) async fn run( token: Option<&str>, json: bool, ) -> Result<()> { - ensure_local_listener(port).await?; + let local_address = ensure_local_listener(port).await?; relay_url .set_scheme(match relay_url.scheme() { "https" => "wss", @@ -111,7 +116,7 @@ pub(crate) async fn run( ready = true; if json { println!("{}", serde_json::to_string(&serde_json::json!({ - "local_url": format!("http://localhost:{port}"), + "local_url": format!("http://{local_address}"), "provider": "sandbox", "public_url": public_url, "expires_at": expires_at, @@ -126,7 +131,7 @@ pub(crate) async fn run( let outbound = outbound_tx.clone(); let http = http.clone(); tokio::spawn(async move { - let response = relay_http_request(http, port, method, uri, headers, body_base64).await; + 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, @@ -150,7 +155,7 @@ pub(crate) async fn run( let (input_tx, input_rx) = mpsc::channel(256); inputs.lock().await.insert(request_id, input_tx); tokio::spawn(async move { - relay_local_websocket(port, request_id, uri, headers, input_rx, outbound.clone()).await; + relay_local_websocket(local_address, request_id, uri, headers, input_rx, outbound.clone()).await; inputs.lock().await.remove(&request_id); }); } @@ -187,13 +192,13 @@ pub(crate) async fn run( async fn relay_http_request( http: reqwest::Client, - port: u16, + local_address: SocketAddr, method: String, uri: String, headers: Vec<(String, String)>, body_base64: String, ) -> Result<(u16, Vec<(String, String)>, String)> { - let url = local_url(port, &uri, "http")?; + 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 { @@ -240,7 +245,7 @@ async fn relay_http_request( } async fn relay_local_websocket( - port: u16, + local_address: SocketAddr, request_id: Uuid, uri: String, headers: Vec<(String, String)>, @@ -248,7 +253,7 @@ async fn relay_local_websocket( outbound: mpsc::Sender, ) { let result = async { - let url = local_url(port, &uri, "ws")?; + let url = local_url(local_address, &uri, "ws")?; let mut request = url .as_str() .into_client_request() @@ -309,19 +314,24 @@ async fn relay_local_websocket( .await; } -fn local_url(port: u16, uri: &str, scheme: &str) -> Result { +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}://127.0.0.1:{port}{uri}")) + let url = Url::parse(&format!("{scheme}://{local_address}{uri}")) .context("build local request URL")?; - if url.host_str() != Some("127.0.0.1") || url.port() != Some(port) { + 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<()> { +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(), @@ -331,7 +341,7 @@ async fn ensure_local_listener(port: u16) -> Result<()> { tokio::time::timeout(Duration::from_millis(500), TcpStream::connect(address)).await, Ok(Ok(_)) ) { - return Ok(()); + return Ok(address); } } bail!( @@ -416,18 +426,31 @@ async fn send_protocol_message( #[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() { - assert!(local_url(4321, "https://example.com/", "http").is_err()); - assert!(local_url(4321, "//example.com/", "http").is_err()); + 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(4321, "/hello?name=world", "http") + 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] @@ -436,8 +459,21 @@ mod tests { .await .expect("bind local listener"); let port = listener.local_addr().expect("listener address").port(); - ensure_local_listener(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/docs/cli.md b/docs/cli.md index df85322..67da683 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -67,7 +67,7 @@ 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 forwards to `127.0.0.1:PORT`, deliberately does not preserve the public `Host` or `Origin`, and never asks a development server 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. +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 diff --git a/docs/deployment.md b/docs/deployment.md index c79b97f..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.4_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/tunnels.md b/docs/tunnels.md index 6790a15..655dd84 100644 --- a/docs/tunnels.md +++ b/docs/tunnels.md @@ -12,7 +12,7 @@ The current implementation supports public HTTP and WebSocket services. It delib ## 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 `127.0.0.1:PORT`. 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. +`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