Skip to content
Draft
13 changes: 13 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ identity.
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
The default deployment mode is mTLS: clients and sandbox workloads present a
certificate signed by the deployment CA before reaching application handlers.
When that service port is bound to loopback, the listener can also accept
plaintext HTTP on the same port for sandbox service subdomains only. That local
browser path is enabled by default and disabled with
`--enable-loopback-service-http=false`; it never serves gateway APIs, auth,
health, metrics, or tunnel routes. The plaintext service router also rejects
browser requests whose Fetch Metadata, Origin, or Referer headers indicate a
cross-origin or sibling-subdomain request.

Supported auth modes:

Expand Down Expand Up @@ -141,6 +148,12 @@ inside the sandbox. The gateway validates the token and sandbox readiness,
sends a targeted `RelayOpen` to the supervisor, then bridges
`TcpForwardFrame::Data` to `RelayFrame::Data` until either side closes.

Browser service URLs use the same supervisor relay path after host-based
routing resolves `sandbox--service.<base-domain>` to a stored service endpoint.
TLS-enabled loopback gateways print `http://` URLs when loopback plaintext
service HTTP is enabled; non-loopback TLS gateways continue to print `https://`
URLs.

For `target.tcp`, the gateway only accepts loopback destinations such as
`localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a
sandbox pod IP; supervisors connect outbound and bridge only the explicit target
Expand Down
49 changes: 49 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ const HELP_TEMPLATE: &str = "\

\x1b[1mSANDBOX COMMANDS\x1b[0m
sandbox: Manage sandboxes
service: Expose sandbox services
forward: Manage port forwarding to a sandbox
logs: View sandbox logs
policy: Manage sandbox policy
Expand Down Expand Up @@ -409,6 +410,13 @@ enum Commands {
command: Option<ForwardCommands>,
},

/// Expose sandbox services.
#[command(help_template = SUBCOMMAND_HELP_TEMPLATE)]
Service {
#[command(subcommand)]
command: Option<ServiceCommands>,
},

/// View sandbox logs.
#[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Logs {
Expand Down Expand Up @@ -1636,6 +1644,24 @@ enum ForwardCommands {
},
}

#[derive(Subcommand, Debug)]
enum ServiceCommands {
/// Expose an HTTP service running inside a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Expose {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
sandbox: String,

/// Service name.
service: String,

/// Loopback TCP port inside the sandbox.
#[arg(long)]
target_port: u16,
},
}

#[tokio::main]
#[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level.
async fn main() -> Result<()> {
Expand Down Expand Up @@ -1920,6 +1946,22 @@ async fn main() -> Result<()> {
}
},

// -----------------------------------------------------------
// Service exposure
// -----------------------------------------------------------
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
sandbox,
service,
target_port,
}),
}) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);
run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls).await?;
}
// -----------------------------------------------------------
// Top-level logs (was `sandbox logs`)
// -----------------------------------------------------------
Expand Down Expand Up @@ -2657,6 +2699,13 @@ async fn main() -> Result<()> {
.print_help()
.expect("Failed to print help");
}
Some(Commands::Service { command: None }) => {
Cli::command()
.find_subcommand_mut("service")
.expect("service subcommand exists")
.print_help()
.expect("Failed to print help");
}
Some(Commands::Policy { command: None }) => {
Cli::command()
.find_subcommand_mut("policy")
Expand Down
119 changes: 108 additions & 11 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,18 @@ use openshell_core::proto::{
ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest,
ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest,
DeleteProviderProfileRequest, DeleteProviderRequest, DeleteSandboxRequest,
DetachSandboxProviderRequest, ExecSandboxRequest, GetClusterInferenceRequest,
GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest,
GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest,
GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest,
LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest,
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource,
PolicyStatus, Provider, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem,
RejectDraftChunkRequest, RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy,
SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue,
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest,
WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init,
DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest,
GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest,
GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRequest,
GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest,
GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest,
ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest,
ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider,
ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest,
RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate,
SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit,
TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest,
exec_sandbox_event, setting_value, tcp_forward_init,
};
use openshell_core::settings::{self, SettingValueKind};
use openshell_core::{ObjectId, ObjectName};
Expand Down Expand Up @@ -3401,6 +3402,57 @@ fn parse_credential_pairs(items: &[String]) -> Result<HashMap<String, String>> {
Ok(map)
}

pub async fn service_expose(
server: &str,
sandbox: &str,
service: &str,
target_port: u16,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let response = client
.expose_service(ExposeServiceRequest {
sandbox: sandbox.to_string(),
service: service.to_string(),
target_port: u32::from(target_port),
domain: true,
})
.await
.map_err(|status| miette::miette!("expose service failed: {status}"))?
.into_inner();

println!(
"{} Exposed service {} on sandbox {} -> 127.0.0.1:{}",
"✓".green().bold(),
service.bold(),
sandbox.bold(),
target_port,
);
if !response.url.is_empty() {
let url = service_url_for_gateway(&response.url, server);
println!(" URL: {}", url.cyan());
}
Ok(())
}

fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String {
let (Ok(mut service_url), Ok(gateway_endpoint)) = (
url::Url::parse(service_url),
url::Url::parse(gateway_endpoint),
) else {
return service_url.to_string();
};

if service_url
.set_port(gateway_endpoint.port_or_known_default())
.is_err()
{
return service_url.to_string();
}

service_url.to_string()
}

pub async fn provider_create(
server: &str,
name: &str,
Expand Down Expand Up @@ -5787,6 +5839,7 @@ mod tests {
inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value,
parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message,
ready_false_condition_message, resolve_from, sandbox_should_persist,
service_url_for_gateway,
};
use crate::TEST_ENV_LOCK;
use hyper::StatusCode;
Expand Down Expand Up @@ -6159,6 +6212,50 @@ mod tests {
assert!(dockerfile_sources_supported_for_gateway(None));
}

#[test]
fn service_url_for_gateway_uses_external_gateway_port() {
assert_eq!(
service_url_for_gateway(
"https://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/",
"https://127.0.0.1:31886"
),
"https://quiet-flamingo--openclaw.navigator.openshell.localhost:31886/"
);
}

#[test]
fn service_url_for_gateway_omits_default_external_port() {
assert_eq!(
service_url_for_gateway(
"https://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/",
"https://gateway.example.com"
),
"https://quiet-flamingo--openclaw.navigator.openshell.localhost/"
);
}

#[test]
fn service_url_for_gateway_preserves_service_scheme() {
assert_eq!(
service_url_for_gateway(
"http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/",
"https://127.0.0.1:31886"
),
"http://quiet-flamingo--openclaw.navigator.openshell.localhost:31886/"
);
}

#[test]
fn service_url_for_gateway_uses_gateway_default_port() {
assert_eq!(
service_url_for_gateway(
"http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/",
"https://gateway.example.com"
),
"http://quiet-flamingo--openclaw.navigator.openshell.localhost:443/"
);
}

#[test]
fn ready_false_condition_message_prefers_reason_and_message() {
let status = SandboxStatus {
Expand Down
9 changes: 9 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,15 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(CreateSshSessionResponse::default()))
}

async fn expose_service(
&self,
_request: tonic::Request<openshell_core::proto::ExposeServiceRequest>,
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(
openshell_core::proto::ServiceEndpointResponse::default(),
))
}

async fn revoke_ssh_session(
&self,
_request: tonic::Request<RevokeSshSessionRequest>,
Expand Down
9 changes: 9 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(CreateSshSessionResponse::default()))
}

async fn expose_service(
&self,
_request: tonic::Request<openshell_core::proto::ExposeServiceRequest>,
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(
openshell_core::proto::ServiceEndpointResponse::default(),
))
}

async fn revoke_ssh_session(
&self,
_request: tonic::Request<RevokeSshSessionRequest>,
Expand Down
9 changes: 9 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,15 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(CreateSshSessionResponse::default()))
}

async fn expose_service(
&self,
_request: tonic::Request<openshell_core::proto::ExposeServiceRequest>,
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(
openshell_core::proto::ServiceEndpointResponse::default(),
))
}

async fn revoke_ssh_session(
&self,
_request: tonic::Request<RevokeSshSessionRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ impl OpenShell for TestOpenShell {
}))
}

async fn expose_service(
&self,
_request: tonic::Request<openshell_core::proto::ExposeServiceRequest>,
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(
openshell_core::proto::ServiceEndpointResponse::default(),
))
}

async fn revoke_ssh_session(
&self,
_request: tonic::Request<RevokeSshSessionRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(CreateSshSessionResponse::default()))
}

async fn expose_service(
&self,
_request: tonic::Request<openshell_core::proto::ExposeServiceRequest>,
) -> Result<Response<openshell_core::proto::ServiceEndpointResponse>, Status> {
Ok(Response::new(
openshell_core::proto::ServiceEndpointResponse::default(),
))
}

async fn revoke_ssh_session(
&self,
_request: tonic::Request<openshell_core::proto::RevokeSshSessionRequest>,
Expand Down
Loading
Loading