Skip to content

Commit 0797fef

Browse files
authored
feat(gateway): add local-domain service routing (NVIDIA#1101)
1 parent 2532687 commit 0797fef

36 files changed

Lines changed: 3408 additions & 74 deletions

architecture/gateway.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ identity.
2525
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
2626
The default deployment mode is mTLS: clients and sandbox workloads present a
2727
certificate signed by the deployment CA before reaching application handlers.
28+
When that service port is bound to loopback, the listener can also accept
29+
plaintext HTTP on the same port for sandbox service subdomains only. That local
30+
browser path is enabled by default and disabled with
31+
`--enable-loopback-service-http=false`; it never serves gateway APIs, auth,
32+
health, metrics, or tunnel routes. The plaintext service router also rejects
33+
browser requests whose Fetch Metadata, Origin, or Referer headers indicate a
34+
cross-origin or sibling-subdomain request.
2835

2936
Supported auth modes:
3037

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

151+
Browser service URLs use the same supervisor relay path after host-based
152+
routing resolves `sandbox--service.<service-routing-domain>` to a stored
153+
service endpoint. Accepted service routing domains are derived from wildcard
154+
DNS SANs configured on the gateway server certificate, with
155+
`openshell.localhost` available by default for loopback gateways. TLS-enabled
156+
loopback gateways print `http://` URLs when loopback plaintext service HTTP is
157+
enabled; non-loopback TLS gateways continue to print `https://` URLs.
158+
144159
For `target.tcp`, the gateway only accepts loopback destinations such as
145160
`localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a
146161
sandbox pod IP; supervisors connect outbound and bridge only the explicit target

crates/openshell-bootstrap/src/pki.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const DEFAULT_SERVER_SANS: &[&str] = &[
2323
"openshell.openshell.svc",
2424
"openshell.openshell.svc.cluster.local",
2525
"localhost",
26+
"openshell.localhost",
27+
"*.openshell.localhost",
2628
"host.docker.internal",
2729
"127.0.0.1",
2830
];

crates/openshell-cli/src/main.rs

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const HELP_TEMPLATE: &str = "\
198198
199199
\x1b[1mSANDBOX COMMANDS\x1b[0m
200200
sandbox: Manage sandboxes
201+
service: Expose sandbox services
201202
forward: Manage port forwarding to a sandbox
202203
logs: View sandbox logs
203204
policy: Manage sandbox policy
@@ -272,6 +273,18 @@ const FORWARD_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
272273
$ openshell forward list
273274
";
274275

276+
const SERVICE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
277+
svc
278+
279+
\x1b[1mEXAMPLES\x1b[0m
280+
$ openshell service expose my-sandbox 8080
281+
$ openshell service expose my-sandbox 8080 web
282+
$ openshell service list
283+
$ openshell service list my-sandbox
284+
$ openshell service get my-sandbox web
285+
$ openshell service delete my-sandbox web
286+
";
287+
275288
const LOGS_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m
276289
lg
277290
@@ -410,6 +423,13 @@ enum Commands {
410423
command: Option<ForwardCommands>,
411424
},
412425

426+
/// Manage sandbox services.
427+
#[command(alias = "svc", after_help = SERVICE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
428+
Service {
429+
#[command(subcommand)]
430+
command: Option<ServiceCommands>,
431+
},
432+
413433
/// View sandbox logs.
414434
#[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
415435
Logs {
@@ -1637,6 +1657,62 @@ enum ForwardCommands {
16371657
},
16381658
}
16391659

1660+
#[derive(Subcommand, Debug)]
1661+
enum ServiceCommands {
1662+
/// Expose an HTTP service running inside a sandbox.
1663+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1664+
Expose {
1665+
/// Sandbox name.
1666+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1667+
sandbox: String,
1668+
1669+
/// Loopback TCP port inside the sandbox.
1670+
#[arg(value_name = "TARGET-PORT")]
1671+
target_port: u16,
1672+
1673+
/// Service name.
1674+
service: Option<String>,
1675+
},
1676+
1677+
/// List exposed sandbox service endpoints.
1678+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1679+
List {
1680+
/// Sandbox name.
1681+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1682+
sandbox: Option<String>,
1683+
1684+
/// Maximum number of endpoints to return.
1685+
#[arg(long, default_value_t = 100)]
1686+
limit: u32,
1687+
1688+
/// Number of endpoints to skip.
1689+
#[arg(long, default_value_t = 0)]
1690+
offset: u32,
1691+
},
1692+
1693+
/// Show one exposed sandbox service endpoint.
1694+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1695+
Get {
1696+
/// Sandbox name.
1697+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1698+
sandbox: String,
1699+
1700+
/// Service name. Omit for the unnamed endpoint.
1701+
service: Option<String>,
1702+
},
1703+
1704+
/// Delete one exposed sandbox service endpoint.
1705+
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
1706+
Delete {
1707+
/// Sandbox name.
1708+
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1709+
sandbox: String,
1710+
1711+
/// Service name. Omit for the unnamed endpoint.
1712+
service: Option<String>,
1713+
},
1714+
}
1715+
16401716
#[tokio::main]
16411717
#[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level.
16421718
async fn main() -> Result<()> {
@@ -1921,6 +1997,43 @@ async fn main() -> Result<()> {
19211997
}
19221998
},
19231999

2000+
// -----------------------------------------------------------
2001+
// Service exposure
2002+
// -----------------------------------------------------------
2003+
Some(Commands::Service {
2004+
command: Some(command),
2005+
}) => {
2006+
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
2007+
let mut tls = tls.with_gateway_name(&ctx.name);
2008+
apply_auth(&mut tls, &ctx.name);
2009+
match command {
2010+
ServiceCommands::Expose {
2011+
sandbox,
2012+
service,
2013+
target_port,
2014+
} => {
2015+
let service = service.unwrap_or_default();
2016+
run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls)
2017+
.await?;
2018+
}
2019+
ServiceCommands::List {
2020+
sandbox,
2021+
limit,
2022+
offset,
2023+
} => {
2024+
run::service_list(&ctx.endpoint, sandbox.as_deref(), limit, offset, &tls)
2025+
.await?;
2026+
}
2027+
ServiceCommands::Get { sandbox, service } => {
2028+
let service = service.unwrap_or_default();
2029+
run::service_get(&ctx.endpoint, &sandbox, &service, &tls).await?;
2030+
}
2031+
ServiceCommands::Delete { sandbox, service } => {
2032+
let service = service.unwrap_or_default();
2033+
run::service_delete(&ctx.endpoint, &sandbox, &service, &tls).await?;
2034+
}
2035+
}
2036+
}
19242037
// -----------------------------------------------------------
19252038
// Top-level logs (was `sandbox logs`)
19262039
// -----------------------------------------------------------
@@ -2658,6 +2771,13 @@ async fn main() -> Result<()> {
26582771
.print_help()
26592772
.expect("Failed to print help");
26602773
}
2774+
Some(Commands::Service { command: None }) => {
2775+
Cli::command()
2776+
.find_subcommand_mut("service")
2777+
.expect("service subcommand exists")
2778+
.print_help()
2779+
.expect("Failed to print help");
2780+
}
26612781
Some(Commands::Policy { command: None }) => {
26622782
Cli::command()
26632783
.find_subcommand_mut("policy")
@@ -3520,4 +3640,185 @@ mod tests {
35203640
}
35213641
}
35223642
}
3643+
3644+
#[test]
3645+
fn service_expose_accepts_positional_target_port_and_service() {
3646+
let cli = Cli::try_parse_from([
3647+
"openshell",
3648+
"service",
3649+
"expose",
3650+
"my-sandbox",
3651+
"8080",
3652+
"api",
3653+
])
3654+
.expect("service expose positional target port should parse");
3655+
3656+
match cli.command {
3657+
Some(Commands::Service {
3658+
command:
3659+
Some(ServiceCommands::Expose {
3660+
sandbox,
3661+
target_port,
3662+
service,
3663+
}),
3664+
}) => {
3665+
assert_eq!(sandbox, "my-sandbox");
3666+
assert_eq!(target_port, 8080);
3667+
assert_eq!(service.as_deref(), Some("api"));
3668+
}
3669+
other => panic!("expected service expose command, got: {other:?}"),
3670+
}
3671+
}
3672+
3673+
#[test]
3674+
fn service_expose_allows_omitted_service_name() {
3675+
let cli = Cli::try_parse_from(["openshell", "service", "expose", "my-sandbox", "8080"])
3676+
.expect("service expose should allow omitting the service name");
3677+
3678+
match cli.command {
3679+
Some(Commands::Service {
3680+
command:
3681+
Some(ServiceCommands::Expose {
3682+
sandbox,
3683+
target_port,
3684+
service,
3685+
}),
3686+
}) => {
3687+
assert_eq!(sandbox, "my-sandbox");
3688+
assert_eq!(target_port, 8080);
3689+
assert_eq!(service, None);
3690+
}
3691+
other => panic!("expected service expose command, got: {other:?}"),
3692+
}
3693+
}
3694+
3695+
#[test]
3696+
fn service_alias_parses_service_commands() {
3697+
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
3698+
.expect("svc alias should parse service commands");
3699+
3700+
match cli.command {
3701+
Some(Commands::Service {
3702+
command:
3703+
Some(ServiceCommands::Expose {
3704+
sandbox,
3705+
target_port,
3706+
service,
3707+
}),
3708+
}) => {
3709+
assert_eq!(sandbox, "my-sandbox");
3710+
assert_eq!(target_port, 8080);
3711+
assert_eq!(service, None);
3712+
}
3713+
other => panic!("expected service expose command, got: {other:?}"),
3714+
}
3715+
}
3716+
3717+
#[test]
3718+
fn service_list_accepts_optional_sandbox_and_paging() {
3719+
let cli = Cli::try_parse_from([
3720+
"openshell",
3721+
"service",
3722+
"list",
3723+
"my-sandbox",
3724+
"--limit",
3725+
"10",
3726+
"--offset",
3727+
"2",
3728+
])
3729+
.expect("service list should parse optional sandbox and paging");
3730+
3731+
match cli.command {
3732+
Some(Commands::Service {
3733+
command:
3734+
Some(ServiceCommands::List {
3735+
sandbox,
3736+
limit,
3737+
offset,
3738+
}),
3739+
}) => {
3740+
assert_eq!(sandbox.as_deref(), Some("my-sandbox"));
3741+
assert_eq!(limit, 10);
3742+
assert_eq!(offset, 2);
3743+
}
3744+
other => panic!("expected service list command, got: {other:?}"),
3745+
}
3746+
3747+
let cli = Cli::try_parse_from(["openshell", "service", "list"])
3748+
.expect("service list should allow omitting sandbox");
3749+
3750+
match cli.command {
3751+
Some(Commands::Service {
3752+
command:
3753+
Some(ServiceCommands::List {
3754+
sandbox,
3755+
limit,
3756+
offset,
3757+
}),
3758+
}) => {
3759+
assert_eq!(sandbox, None);
3760+
assert_eq!(limit, 100);
3761+
assert_eq!(offset, 0);
3762+
}
3763+
other => panic!("expected service list command, got: {other:?}"),
3764+
}
3765+
}
3766+
3767+
#[test]
3768+
fn service_get_accepts_optional_service_name() {
3769+
let cli = Cli::try_parse_from(["openshell", "service", "get", "my-sandbox", "api"])
3770+
.expect("service get should parse service name");
3771+
3772+
match cli.command {
3773+
Some(Commands::Service {
3774+
command: Some(ServiceCommands::Get { sandbox, service }),
3775+
}) => {
3776+
assert_eq!(sandbox, "my-sandbox");
3777+
assert_eq!(service.as_deref(), Some("api"));
3778+
}
3779+
other => panic!("expected service get command, got: {other:?}"),
3780+
}
3781+
3782+
let cli = Cli::try_parse_from(["openshell", "service", "get", "my-sandbox"])
3783+
.expect("service get should allow omitting service name");
3784+
3785+
match cli.command {
3786+
Some(Commands::Service {
3787+
command: Some(ServiceCommands::Get { sandbox, service }),
3788+
}) => {
3789+
assert_eq!(sandbox, "my-sandbox");
3790+
assert_eq!(service, None);
3791+
}
3792+
other => panic!("expected service get command, got: {other:?}"),
3793+
}
3794+
}
3795+
3796+
#[test]
3797+
fn service_delete_accepts_optional_service_name() {
3798+
let cli = Cli::try_parse_from(["openshell", "service", "delete", "my-sandbox", "api"])
3799+
.expect("service delete should parse service name");
3800+
3801+
match cli.command {
3802+
Some(Commands::Service {
3803+
command: Some(ServiceCommands::Delete { sandbox, service }),
3804+
}) => {
3805+
assert_eq!(sandbox, "my-sandbox");
3806+
assert_eq!(service.as_deref(), Some("api"));
3807+
}
3808+
other => panic!("expected service delete command, got: {other:?}"),
3809+
}
3810+
3811+
let cli = Cli::try_parse_from(["openshell", "service", "delete", "my-sandbox"])
3812+
.expect("service delete should allow omitting service name");
3813+
3814+
match cli.command {
3815+
Some(Commands::Service {
3816+
command: Some(ServiceCommands::Delete { sandbox, service }),
3817+
}) => {
3818+
assert_eq!(sandbox, "my-sandbox");
3819+
assert_eq!(service, None);
3820+
}
3821+
other => panic!("expected service delete command, got: {other:?}"),
3822+
}
3823+
}
35233824
}

0 commit comments

Comments
 (0)