Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 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,14 @@ 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.<service-routing-domain>` to a stored
service endpoint. Accepted service routing domains are derived from wildcard
DNS SANs configured on the gateway server certificate, with
`openshell.localhost` available by default for loopback gateways. 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
2 changes: 2 additions & 0 deletions crates/openshell-bootstrap/src/pki.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const DEFAULT_SERVER_SANS: &[&str] = &[
"openshell.openshell.svc",
"openshell.openshell.svc.cluster.local",
"localhost",
"openshell.localhost",
"*.openshell.localhost",
"host.docker.internal",
"127.0.0.1",
];
Expand Down
301 changes: 301 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 @@ -272,6 +273,18 @@ const FORWARD_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
$ openshell forward list
";

const SERVICE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
svc

\x1b[1mEXAMPLES\x1b[0m
$ openshell service expose my-sandbox 8080
$ openshell service expose my-sandbox 8080 web
$ openshell service list
$ openshell service list my-sandbox
$ openshell service get my-sandbox web
$ openshell service delete my-sandbox web
";

const LOGS_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
lg

Expand Down Expand Up @@ -409,6 +422,13 @@ enum Commands {
command: Option<ForwardCommands>,
},

/// Manage sandbox services.
#[command(alias = "svc", after_help = SERVICE_EXAMPLES, 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 +1656,62 @@ 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,

/// Loopback TCP port inside the sandbox.
#[arg(value_name = "TARGET-PORT")]
target_port: u16,

/// Service name.
service: Option<String>,
},

/// List exposed sandbox service endpoints.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
List {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
sandbox: Option<String>,

/// Maximum number of endpoints to return.
#[arg(long, default_value_t = 100)]
limit: u32,

/// Number of endpoints to skip.
#[arg(long, default_value_t = 0)]
offset: u32,
},

/// Show one exposed sandbox service endpoint.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Get {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
sandbox: String,

/// Service name. Omit for the unnamed endpoint.
service: Option<String>,
},

/// Delete one exposed sandbox service endpoint.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Delete {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
sandbox: String,

/// Service name. Omit for the unnamed endpoint.
service: Option<String>,
},
}

#[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 +1996,43 @@ async fn main() -> Result<()> {
}
},

// -----------------------------------------------------------
// Service exposure
// -----------------------------------------------------------
Some(Commands::Service {
command: Some(command),
}) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);
match command {
ServiceCommands::Expose {
sandbox,
service,
target_port,
} => {
let service = service.unwrap_or_default();
run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls)
.await?;
}
ServiceCommands::List {
sandbox,
limit,
offset,
} => {
run::service_list(&ctx.endpoint, sandbox.as_deref(), limit, offset, &tls)
.await?;
}
ServiceCommands::Get { sandbox, service } => {
let service = service.unwrap_or_default();
run::service_get(&ctx.endpoint, &sandbox, &service, &tls).await?;
}
ServiceCommands::Delete { sandbox, service } => {
let service = service.unwrap_or_default();
run::service_delete(&ctx.endpoint, &sandbox, &service, &tls).await?;
}
}
}
// -----------------------------------------------------------
// Top-level logs (was `sandbox logs`)
// -----------------------------------------------------------
Expand Down Expand Up @@ -2657,6 +2770,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 Expand Up @@ -3519,4 +3639,185 @@ mod tests {
}
}
}

#[test]
fn service_expose_accepts_positional_target_port_and_service() {
let cli = Cli::try_parse_from([
"openshell",
"service",
"expose",
"my-sandbox",
"8080",
"api",
])
.expect("service expose positional target port should parse");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
sandbox,
target_port,
service,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service.as_deref(), Some("api"));
}
other => panic!("expected service expose command, got: {other:?}"),
}
}

#[test]
fn service_expose_allows_omitted_service_name() {
let cli = Cli::try_parse_from(["openshell", "service", "expose", "my-sandbox", "8080"])
.expect("service expose should allow omitting the service name");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
sandbox,
target_port,
service,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service, None);
}
other => panic!("expected service expose command, got: {other:?}"),
}
}

#[test]
fn service_alias_parses_service_commands() {
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
.expect("svc alias should parse service commands");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::Expose {
sandbox,
target_port,
service,
}),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(target_port, 8080);
assert_eq!(service, None);
}
other => panic!("expected service expose command, got: {other:?}"),
}
}

#[test]
fn service_list_accepts_optional_sandbox_and_paging() {
let cli = Cli::try_parse_from([
"openshell",
"service",
"list",
"my-sandbox",
"--limit",
"10",
"--offset",
"2",
])
.expect("service list should parse optional sandbox and paging");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::List {
sandbox,
limit,
offset,
}),
}) => {
assert_eq!(sandbox.as_deref(), Some("my-sandbox"));
assert_eq!(limit, 10);
assert_eq!(offset, 2);
}
other => panic!("expected service list command, got: {other:?}"),
}

let cli = Cli::try_parse_from(["openshell", "service", "list"])
.expect("service list should allow omitting sandbox");

match cli.command {
Some(Commands::Service {
command:
Some(ServiceCommands::List {
sandbox,
limit,
offset,
}),
}) => {
assert_eq!(sandbox, None);
assert_eq!(limit, 100);
assert_eq!(offset, 0);
}
other => panic!("expected service list command, got: {other:?}"),
}
}

#[test]
fn service_get_accepts_optional_service_name() {
let cli = Cli::try_parse_from(["openshell", "service", "get", "my-sandbox", "api"])
.expect("service get should parse service name");

match cli.command {
Some(Commands::Service {
command: Some(ServiceCommands::Get { sandbox, service }),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(service.as_deref(), Some("api"));
}
other => panic!("expected service get command, got: {other:?}"),
}

let cli = Cli::try_parse_from(["openshell", "service", "get", "my-sandbox"])
.expect("service get should allow omitting service name");

match cli.command {
Some(Commands::Service {
command: Some(ServiceCommands::Get { sandbox, service }),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(service, None);
}
other => panic!("expected service get command, got: {other:?}"),
}
}

#[test]
fn service_delete_accepts_optional_service_name() {
let cli = Cli::try_parse_from(["openshell", "service", "delete", "my-sandbox", "api"])
.expect("service delete should parse service name");

match cli.command {
Some(Commands::Service {
command: Some(ServiceCommands::Delete { sandbox, service }),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(service.as_deref(), Some("api"));
}
other => panic!("expected service delete command, got: {other:?}"),
}

let cli = Cli::try_parse_from(["openshell", "service", "delete", "my-sandbox"])
.expect("service delete should allow omitting service name");

match cli.command {
Some(Commands::Service {
command: Some(ServiceCommands::Delete { sandbox, service }),
}) => {
assert_eq!(sandbox, "my-sandbox");
assert_eq!(service, None);
}
other => panic!("expected service delete command, got: {other:?}"),
}
}
}
Loading
Loading