mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
feat(service): add bearer authorization passthrough (#3796)
* feat(service): add bearer authorization passthrough Signed-off-by: Derek Carr <decarr@redhat.com> * docs(sdk): add service authorization migration guide Signed-off-by: Derek Carr <decarr@redhat.com> * fix(server): remove stale version import Signed-off-by: Derek Carr <decarr@redhat.com> * docs(upgrade): remove service authorization SDK guide Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): relabel provider readiness TLS mount Signed-off-by: Derek Carr <decarr@redhat.com> * test(e2e): stabilize exposed service routing Signed-off-by: Derek Carr <decarr@redhat.com> * test(e2e): support HTTPS service routing Signed-off-by: Derek Carr <decarr@redhat.com> --------- Signed-off-by: Derek Carr <decarr@redhat.com>
This commit is contained in:
+29
@@ -178,6 +178,35 @@ Rust-based e2e tests that exercise the `openshell` CLI binary as a subprocess.
|
||||
They live in the `openshell-e2e` crate and use a shared harness for sandbox
|
||||
lifecycle management, output parsing, and cleanup.
|
||||
|
||||
Exposed service URLs use virtual hostnames for gateway routing. Host-side tests
|
||||
must connect the TCP socket directly to a reachable gateway listener address,
|
||||
normally loopback, and send the service URL authority in the HTTP `Host`
|
||||
header. Do not resolve `*.openshell.localhost`; resolver support for arbitrary
|
||||
`.localhost` subdomains varies across local and CI environments.
|
||||
|
||||
Treat the advertised service URL scheme as authoritative. For HTTPS, use the
|
||||
virtual service hostname for TLS SNI and the configured gateway trust roots.
|
||||
When the listener requires mTLS, present the active gateway client identity;
|
||||
the local e2e wrappers register these materials under
|
||||
`$XDG_CONFIG_HOME/openshell/gateways/$OPENSHELL_GATEWAY/mtls/`. Do not downgrade
|
||||
an HTTPS service URL to plaintext when dialing loopback. Parse the URL and load
|
||||
TLS material before entering a readiness loop so permanent configuration
|
||||
errors fail immediately. Retry only transient connection failures and
|
||||
documented readiness responses, and include the last observation in timeout
|
||||
diagnostics.
|
||||
|
||||
Verify exposed-service tests in both the default local mode and the
|
||||
CI-equivalent HTTPS mode:
|
||||
|
||||
```shell
|
||||
mise run e2e:rust
|
||||
OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false mise run e2e:rust
|
||||
```
|
||||
|
||||
When more than one test needs this behavior, put the transport in the shared
|
||||
Rust e2e harness and require callers to use it instead of duplicating DNS,
|
||||
HTTP `Host`, TLS SNI, and mTLS handling.
|
||||
|
||||
Suites:
|
||||
|
||||
- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
|
||||
|
||||
@@ -19,7 +19,7 @@ use openshell_bootstrap::{
|
||||
use openshell_cli::completers;
|
||||
use openshell_cli::run;
|
||||
use openshell_cli::tls::TlsOptions;
|
||||
use openshell_core::proto::GpuResourceRequirements;
|
||||
use openshell_core::proto::{GpuResourceRequirements, ServiceAuthorizationMode};
|
||||
|
||||
/// Resolved gateway context: name + gateway endpoint.
|
||||
struct GatewayContext {
|
||||
@@ -770,6 +770,22 @@ enum OutputFormat {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
|
||||
enum CliServiceAuthorizationMode {
|
||||
#[default]
|
||||
Strip,
|
||||
BearerPassthrough,
|
||||
}
|
||||
|
||||
impl From<CliServiceAuthorizationMode> for ServiceAuthorizationMode {
|
||||
fn from(value: CliServiceAuthorizationMode) -> Self {
|
||||
match value {
|
||||
CliServiceAuthorizationMode::Strip => Self::Strip,
|
||||
CliServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
enum CliProviderRefreshStrategy {
|
||||
Oauth2RefreshToken,
|
||||
@@ -1520,6 +1536,10 @@ enum SandboxCommands {
|
||||
)]
|
||||
expose: Option<u16>,
|
||||
|
||||
/// Handling for an incoming application Authorization header.
|
||||
#[arg(long, value_enum, default_value_t, requires = "expose")]
|
||||
expose_authorization_mode: CliServiceAuthorizationMode,
|
||||
|
||||
/// Allocate a pseudo-terminal for the remote command.
|
||||
/// Defaults to auto-detection (on when stdin and stdout are terminals).
|
||||
/// Use --tty to force a PTY even when auto-detection fails, or
|
||||
@@ -2369,6 +2389,10 @@ enum ServiceCommands {
|
||||
|
||||
/// Service name.
|
||||
service: Option<String>,
|
||||
|
||||
/// Handling for an incoming application Authorization header.
|
||||
#[arg(long, value_enum, default_value_t)]
|
||||
authorization_mode: CliServiceAuthorizationMode,
|
||||
},
|
||||
|
||||
/// List exposed sandbox service endpoints.
|
||||
@@ -2914,6 +2938,7 @@ async fn run_async() -> Result<()> {
|
||||
sandbox,
|
||||
service,
|
||||
target_port,
|
||||
authorization_mode,
|
||||
} => {
|
||||
let service = service.unwrap_or_default();
|
||||
run::service_expose(
|
||||
@@ -2921,6 +2946,7 @@ async fn run_async() -> Result<()> {
|
||||
&sandbox,
|
||||
&service,
|
||||
target_port,
|
||||
authorization_mode.into(),
|
||||
&cli.workspace,
|
||||
&tls,
|
||||
)
|
||||
@@ -3324,6 +3350,7 @@ async fn run_async() -> Result<()> {
|
||||
policy,
|
||||
forward,
|
||||
expose,
|
||||
expose_authorization_mode,
|
||||
tty,
|
||||
no_tty,
|
||||
detach,
|
||||
@@ -3421,6 +3448,7 @@ async fn run_async() -> Result<()> {
|
||||
policy: policy.as_deref(),
|
||||
forward,
|
||||
expose,
|
||||
expose_authorization_mode: expose_authorization_mode.into(),
|
||||
command: &command,
|
||||
tty_override,
|
||||
auto_providers_override,
|
||||
@@ -6716,9 +6744,19 @@ mod tests {
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Sandbox {
|
||||
command: Some(SandboxCommands::Create { expose, detach, .. }),
|
||||
command:
|
||||
Some(SandboxCommands::Create {
|
||||
expose,
|
||||
expose_authorization_mode,
|
||||
detach,
|
||||
..
|
||||
}),
|
||||
}) => {
|
||||
assert_eq!(expose, Some(4500));
|
||||
assert_eq!(
|
||||
expose_authorization_mode,
|
||||
CliServiceAuthorizationMode::Strip
|
||||
);
|
||||
assert!(detach);
|
||||
}
|
||||
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
|
||||
@@ -6746,6 +6784,44 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_create_parses_bearer_passthrough_and_requires_expose() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"openshell",
|
||||
"sandbox",
|
||||
"create",
|
||||
"--expose",
|
||||
"4500",
|
||||
"--expose-authorization-mode",
|
||||
"bearer-passthrough",
|
||||
])
|
||||
.expect("create-time authorization mode should parse with --expose");
|
||||
match cli.command {
|
||||
Some(Commands::Sandbox {
|
||||
command:
|
||||
Some(SandboxCommands::Create {
|
||||
expose_authorization_mode,
|
||||
..
|
||||
}),
|
||||
}) => assert_eq!(
|
||||
expose_authorization_mode,
|
||||
CliServiceAuthorizationMode::BearerPassthrough
|
||||
),
|
||||
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
|
||||
}
|
||||
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"openshell",
|
||||
"sandbox",
|
||||
"create",
|
||||
"--expose-authorization-mode",
|
||||
"bearer-passthrough",
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_expose_accepts_positional_target_port_and_service() {
|
||||
let cli = Cli::try_parse_from([
|
||||
@@ -6765,11 +6841,13 @@ mod tests {
|
||||
sandbox,
|
||||
target_port,
|
||||
service,
|
||||
authorization_mode,
|
||||
}),
|
||||
}) => {
|
||||
assert_eq!(sandbox, "my-sandbox");
|
||||
assert_eq!(target_port, 8080);
|
||||
assert_eq!(service.as_deref(), Some("api"));
|
||||
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
|
||||
}
|
||||
other => panic!("expected service expose command, got: {other:?}"),
|
||||
}
|
||||
@@ -6787,16 +6865,45 @@ mod tests {
|
||||
sandbox,
|
||||
target_port,
|
||||
service,
|
||||
authorization_mode,
|
||||
}),
|
||||
}) => {
|
||||
assert_eq!(sandbox, "my-sandbox");
|
||||
assert_eq!(target_port, 8080);
|
||||
assert_eq!(service, None);
|
||||
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
|
||||
}
|
||||
other => panic!("expected service expose command, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_expose_parses_bearer_passthrough() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"openshell",
|
||||
"service",
|
||||
"expose",
|
||||
"my-sandbox",
|
||||
"4500",
|
||||
"--authorization-mode",
|
||||
"bearer-passthrough",
|
||||
])
|
||||
.expect("service authorization mode should parse");
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Service {
|
||||
command:
|
||||
Some(ServiceCommands::Expose {
|
||||
authorization_mode, ..
|
||||
}),
|
||||
}) => assert_eq!(
|
||||
authorization_mode,
|
||||
CliServiceAuthorizationMode::BearerPassthrough
|
||||
),
|
||||
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"])
|
||||
@@ -6809,6 +6916,7 @@ mod tests {
|
||||
sandbox,
|
||||
target_port,
|
||||
service,
|
||||
..
|
||||
}),
|
||||
}) => {
|
||||
assert_eq!(sandbox, "my-sandbox");
|
||||
|
||||
@@ -58,9 +58,9 @@ use openshell_core::proto::{
|
||||
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
|
||||
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
|
||||
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||
SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SettingScope, StartSandboxRequest,
|
||||
StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
|
||||
WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
|
||||
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpointResponse, SettingScope,
|
||||
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
|
||||
UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
|
||||
};
|
||||
use openshell_core::settings;
|
||||
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
|
||||
@@ -443,6 +443,7 @@ pub struct SandboxCreateConfig<'a> {
|
||||
pub policy: Option<&'a str>,
|
||||
pub forward: Option<ForwardSpec>,
|
||||
pub expose: Option<u16>,
|
||||
pub expose_authorization_mode: ServiceAuthorizationMode,
|
||||
pub command: &'a [String],
|
||||
pub tty_override: Option<bool>,
|
||||
pub auto_providers_override: Option<bool>,
|
||||
@@ -472,6 +473,7 @@ impl Default for SandboxCreateConfig<'_> {
|
||||
policy: None,
|
||||
forward: None,
|
||||
expose: None,
|
||||
expose_authorization_mode: ServiceAuthorizationMode::Strip,
|
||||
command: &[],
|
||||
tty_override: None,
|
||||
auto_providers_override: None,
|
||||
@@ -509,6 +511,7 @@ pub async fn sandbox_create(
|
||||
policy,
|
||||
forward,
|
||||
expose,
|
||||
expose_authorization_mode,
|
||||
command,
|
||||
tty_override,
|
||||
auto_providers_override,
|
||||
@@ -693,6 +696,7 @@ pub async fn sandbox_create(
|
||||
.map(|target_port| SandboxServiceExposure {
|
||||
service: String::new(),
|
||||
target_port: u32::from(target_port),
|
||||
authorization_mode: expose_authorization_mode as i32,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
@@ -3823,11 +3827,20 @@ pub async fn service_expose(
|
||||
sandbox: &str,
|
||||
service: &str,
|
||||
target_port: u16,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
workspace: &str,
|
||||
tls: &TlsOptions,
|
||||
) -> Result<()> {
|
||||
let response =
|
||||
expose_service_endpoint(server, sandbox, service, target_port, workspace, tls).await?;
|
||||
let response = expose_service_endpoint(
|
||||
server,
|
||||
sandbox,
|
||||
service,
|
||||
target_port,
|
||||
authorization_mode,
|
||||
workspace,
|
||||
tls,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if service.is_empty() {
|
||||
println!(
|
||||
@@ -3857,6 +3870,7 @@ async fn expose_service_endpoint(
|
||||
sandbox: &str,
|
||||
service: &str,
|
||||
target_port: u16,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
workspace: &str,
|
||||
tls: &TlsOptions,
|
||||
) -> Result<ServiceEndpointResponse> {
|
||||
@@ -3868,6 +3882,7 @@ async fn expose_service_endpoint(
|
||||
name: service.to_string(),
|
||||
target_port: u32::from(target_port),
|
||||
domain: true,
|
||||
authorization_mode: authorization_mode as i32,
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||
workspace.to_string(),
|
||||
)),
|
||||
@@ -4040,6 +4055,7 @@ fn print_service_endpoint_table(
|
||||
.map_or("", |m| m.workspace.as_str());
|
||||
let service = service_display_name(&endpoint.name).to_string();
|
||||
let target = format!("127.0.0.1:{}", endpoint.target_port);
|
||||
let authorization = service_authorization_mode_name(endpoint.authorization_mode);
|
||||
let url = if response.url.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -4050,6 +4066,7 @@ fn print_service_endpoint_table(
|
||||
endpoint.sandbox.clone(),
|
||||
service,
|
||||
target,
|
||||
authorization,
|
||||
url,
|
||||
))
|
||||
})
|
||||
@@ -4061,7 +4078,7 @@ fn print_service_endpoint_table(
|
||||
|
||||
let ws_width = if all_workspaces {
|
||||
rows.iter()
|
||||
.map(|(ws, _, _, _, _)| ws.len())
|
||||
.map(|(ws, _, _, _, _, _)| ws.len())
|
||||
.max()
|
||||
.unwrap_or(9)
|
||||
.max(9)
|
||||
@@ -4070,50 +4087,52 @@ fn print_service_endpoint_table(
|
||||
};
|
||||
let sandbox_width = rows
|
||||
.iter()
|
||||
.map(|(_, sandbox, _, _, _)| sandbox.len())
|
||||
.map(|(_, sandbox, _, _, _, _)| sandbox.len())
|
||||
.max()
|
||||
.unwrap_or(7)
|
||||
.max(7);
|
||||
let service_width = rows
|
||||
.iter()
|
||||
.map(|(_, _, service, _, _)| service.len())
|
||||
.map(|(_, _, service, _, _, _)| service.len())
|
||||
.max()
|
||||
.unwrap_or(7)
|
||||
.max(7);
|
||||
let target_width = rows
|
||||
.iter()
|
||||
.map(|(_, _, _, target, _)| target.len())
|
||||
.map(|(_, _, _, target, _, _)| target.len())
|
||||
.max()
|
||||
.unwrap_or(6)
|
||||
.max(6);
|
||||
|
||||
if all_workspaces {
|
||||
println!(
|
||||
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
||||
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
|
||||
"WORKSPACE".bold(),
|
||||
"SANDBOX".bold(),
|
||||
"SERVICE".bold(),
|
||||
"TARGET".bold(),
|
||||
"AUTHORIZATION".bold(),
|
||||
"URL".bold(),
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
||||
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
|
||||
"SANDBOX".bold(),
|
||||
"SERVICE".bold(),
|
||||
"TARGET".bold(),
|
||||
"AUTHORIZATION".bold(),
|
||||
"URL".bold(),
|
||||
);
|
||||
}
|
||||
|
||||
for (workspace, sandbox, service, target, url) in rows {
|
||||
for (workspace, sandbox, service, target, authorization, url) in rows {
|
||||
if all_workspaces {
|
||||
println!(
|
||||
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
|
||||
"{workspace:<ws_width$} {sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {url}"
|
||||
"{sandbox:<sandbox_width$} {service:<service_width$} {target:<target_width$} {authorization:<20} {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4139,6 +4158,7 @@ fn service_endpoint_to_json(
|
||||
"sandbox": endpoint.sandbox,
|
||||
"service": endpoint.name,
|
||||
"target_port": endpoint.target_port,
|
||||
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
|
||||
"url": url,
|
||||
}))
|
||||
}
|
||||
@@ -4147,6 +4167,13 @@ fn service_display_name(service: &str) -> &str {
|
||||
if service.is_empty() { "-" } else { service }
|
||||
}
|
||||
|
||||
fn service_authorization_mode_name(mode: i32) -> &'static str {
|
||||
match ServiceAuthorizationMode::try_from(mode).unwrap_or(ServiceAuthorizationMode::Strip) {
|
||||
ServiceAuthorizationMode::BearerPassthrough => "bearer_passthrough",
|
||||
ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip => "strip",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read gcloud Application Default Credentials from disk.
|
||||
///
|
||||
/// Returns `(client_id, client_secret, refresh_token)`.
|
||||
@@ -6521,8 +6548,9 @@ mod tests {
|
||||
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
|
||||
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
|
||||
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceEndpoint,
|
||||
ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta,
|
||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||
ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole,
|
||||
datamodel::v1::ObjectMeta,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -6639,6 +6667,7 @@ mod tests {
|
||||
sandbox: "api".to_string(),
|
||||
name: String::new(),
|
||||
target_port: 8080,
|
||||
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||
..Default::default()
|
||||
}),
|
||||
url: "https://api.openshell.localhost:3000/".to_string(),
|
||||
@@ -6653,6 +6682,7 @@ mod tests {
|
||||
"sandbox": "api",
|
||||
"service": "",
|
||||
"target_port": 8080,
|
||||
"authorization_mode": "bearer_passthrough",
|
||||
"url": "https://api.openshell.localhost:17670/",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2784,6 +2784,8 @@ async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() {
|
||||
name: Some("sandbox"),
|
||||
keep: false,
|
||||
expose: Some(4500),
|
||||
expose_authorization_mode:
|
||||
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough,
|
||||
detach: true,
|
||||
..test_config()
|
||||
},
|
||||
@@ -2799,9 +2801,38 @@ async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() {
|
||||
assert_eq!(create_requests[0].service_exposures.len(), 1);
|
||||
assert_eq!(create_requests[0].service_exposures[0].service, "");
|
||||
assert_eq!(create_requests[0].service_exposures[0].target_port, 4500);
|
||||
assert_eq!(
|
||||
create_requests[0].service_exposures[0].authorization_mode(),
|
||||
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough
|
||||
);
|
||||
assert!(expose_service_requests(&server).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_expose_forwards_bearer_passthrough_mode() {
|
||||
let server = run_server().await;
|
||||
let tls = test_tls(&server);
|
||||
|
||||
run::service_expose(
|
||||
&server.endpoint,
|
||||
"sandbox",
|
||||
"codex",
|
||||
4500,
|
||||
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough,
|
||||
"default",
|
||||
&tls,
|
||||
)
|
||||
.await
|
||||
.expect("service expose should succeed");
|
||||
|
||||
let requests = expose_service_requests(&server).await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].authorization_mode(),
|
||||
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() {
|
||||
let server = run_server().await;
|
||||
|
||||
@@ -56,8 +56,10 @@ portable workload shape and driver config. Failures map to a typed `SdkError`
|
||||
with a discriminable kind.
|
||||
|
||||
Set `SandboxSpec::service_exposures` to register named or unnamed loopback HTTP
|
||||
services during creation. Each `ServiceExposure` contains a service name and a
|
||||
target port; an empty name selects the unnamed endpoint. The returned
|
||||
services during creation. Each `ServiceExposure` contains a service name, a
|
||||
target port, and an authorization mode; an empty name selects the unnamed
|
||||
endpoint. Authorization is stripped by default. Select `BearerPassthrough` only
|
||||
when the sandbox application validates its own bearer credential. The returned
|
||||
`SandboxRef::service_urls` map contains each routed URL under the same name.
|
||||
|
||||
Curated calls without a workspace argument explicitly select the `default`
|
||||
|
||||
@@ -1359,6 +1359,9 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
|
||||
.map(|exposure| proto::SandboxServiceExposure {
|
||||
service: exposure.service,
|
||||
target_port: u32::from(exposure.target_port),
|
||||
authorization_mode: proto::ServiceAuthorizationMode::from(
|
||||
exposure.authorization_mode,
|
||||
) as i32,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
@@ -1397,6 +1400,9 @@ fn create_sandbox_from_template_request(
|
||||
.map(|exposure| proto::SandboxServiceExposure {
|
||||
service: exposure.service,
|
||||
target_port: u32::from(exposure.target_port),
|
||||
authorization_mode: proto::ServiceAuthorizationMode::from(
|
||||
exposure.authorization_mode,
|
||||
) as i32,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
@@ -54,6 +54,6 @@ pub use types::{
|
||||
LogLine, PlatformEvent, SandboxPhase, SandboxRef, SandboxResources, SandboxRestartPolicy,
|
||||
SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec,
|
||||
SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceExposure, ServiceStatus,
|
||||
WatchEvent, WatchOptions, WorkspaceRef,
|
||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||
ServiceExposure, ServiceStatus, WatchEvent, WatchOptions, WorkspaceRef,
|
||||
};
|
||||
|
||||
@@ -296,6 +296,27 @@ pub struct ServiceExposure {
|
||||
pub service: String,
|
||||
/// Loopback TCP port inside the sandbox.
|
||||
pub target_port: u16,
|
||||
/// Whether the gateway strips or forwards an application bearer credential.
|
||||
pub authorization_mode: ServiceAuthorizationMode,
|
||||
}
|
||||
|
||||
/// Handling for an incoming application `Authorization` header.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ServiceAuthorizationMode {
|
||||
/// Remove the header before proxying to the sandbox service.
|
||||
#[default]
|
||||
Strip,
|
||||
/// Forward one syntactically valid bearer credential unchanged.
|
||||
BearerPassthrough,
|
||||
}
|
||||
|
||||
impl From<ServiceAuthorizationMode> for proto::ServiceAuthorizationMode {
|
||||
fn from(value: ServiceAuthorizationMode) -> Self {
|
||||
match value {
|
||||
ServiceAuthorizationMode::Strip => Self::Strip,
|
||||
ServiceAuthorizationMode::BearerPassthrough => Self::BearerPassthrough,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller intent for creating a sandbox from a named workload template.
|
||||
|
||||
@@ -13,8 +13,8 @@ use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer};
|
||||
use openshell_sdk::{
|
||||
AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError,
|
||||
RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec,
|
||||
SandboxTemplateListOptions, ServiceExposure, ServiceStatus as SdkServiceStatus, WatchEvent,
|
||||
WatchOptions,
|
||||
SandboxTemplateListOptions, ServiceAuthorizationMode, ServiceExposure,
|
||||
ServiceStatus as SdkServiceStatus, WatchEvent, WatchOptions,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -1139,6 +1139,7 @@ async fn create_sandbox_passes_spec_through() {
|
||||
service_exposures: vec![ServiceExposure {
|
||||
service: "web".to_string(),
|
||||
target_port: 8080,
|
||||
authorization_mode: ServiceAuthorizationMode::BearerPassthrough,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -1158,6 +1159,10 @@ async fn create_sandbox_passes_spec_through() {
|
||||
assert_eq!(observed.service_exposures.len(), 1);
|
||||
assert_eq!(observed.service_exposures[0].service, "web");
|
||||
assert_eq!(observed.service_exposures[0].target_port, 8080);
|
||||
assert_eq!(
|
||||
observed.service_exposures[0].authorization_mode(),
|
||||
proto::ServiceAuthorizationMode::BearerPassthrough
|
||||
);
|
||||
let observed_spec = observed.spec.unwrap();
|
||||
assert!(
|
||||
observed_spec
|
||||
|
||||
@@ -25,6 +25,8 @@ use hyper_util::rt::TokioIo;
|
||||
use openshell_core::extension_protocol::{
|
||||
ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use openshell_core::proto::ServiceAuthorizationMode;
|
||||
use openshell_core::proto::compute::v1::{
|
||||
AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest,
|
||||
DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements,
|
||||
@@ -9509,6 +9511,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||
use openshell_core::proto::open_shell_server::OpenShell;
|
||||
use openshell_core::proto::{
|
||||
CreateSandboxRequest, SandboxServiceExposure, SandboxSpec, SandboxWorkloadConfig,
|
||||
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, WorkspaceRole,
|
||||
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||
WorkspaceMember, WorkspaceRole,
|
||||
};
|
||||
use openshell_core::rpc_error::StatusExt;
|
||||
use std::collections::HashMap;
|
||||
@@ -113,6 +114,7 @@ async fn create_sandbox_replay_preserves_service_urls() {
|
||||
service_exposures: vec![SandboxServiceExposure {
|
||||
service: "web".into(),
|
||||
target_port: 8080,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}],
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
..Default::default()
|
||||
@@ -124,13 +126,26 @@ async fn create_sandbox_replay_preserves_service_urls() {
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
let replay = service
|
||||
.create_sandbox(authed_request(request))
|
||||
.create_sandbox(authed_request(request.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true");
|
||||
assert_eq!(replay.get_ref().service_urls, original.service_urls);
|
||||
assert_eq!(replay.into_inner(), original);
|
||||
|
||||
let mut changed_authorization = request;
|
||||
changed_authorization.service_exposures[0].authorization_mode =
|
||||
ServiceAuthorizationMode::BearerPassthrough as i32;
|
||||
assert_eq!(
|
||||
reason(
|
||||
&service
|
||||
.create_sandbox(authed_request(changed_authorization))
|
||||
.await
|
||||
.unwrap_err()
|
||||
),
|
||||
"REQUEST_ID_PAYLOAD_MISMATCH"
|
||||
);
|
||||
}
|
||||
|
||||
async fn exercise_backend(url: &str) {
|
||||
|
||||
@@ -680,6 +680,11 @@ async fn handle_create_sandbox_inner(
|
||||
&sandbox,
|
||||
&exposure.service,
|
||||
exposure.target_port,
|
||||
super::service::validate_service_exposure_request(
|
||||
&exposure.service,
|
||||
exposure.target_port,
|
||||
exposure.authorization_mode,
|
||||
)?,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -741,7 +746,11 @@ fn validate_create_sandbox_request_pre_io(
|
||||
}
|
||||
let mut service_names = HashSet::with_capacity(request.service_exposures.len());
|
||||
for exposure in &request.service_exposures {
|
||||
super::service::validate_service_exposure_request(&exposure.service, exposure.target_port)?;
|
||||
super::service::validate_service_exposure_request(
|
||||
&exposure.service,
|
||||
exposure.target_port,
|
||||
exposure.authorization_mode,
|
||||
)?;
|
||||
if !service_names.insert(exposure.service.as_str()) {
|
||||
return Err(Status::invalid_argument(format!(
|
||||
"duplicate service exposure name: '{}'",
|
||||
@@ -3924,7 +3933,9 @@ mod tests {
|
||||
test_server_state_with_driver,
|
||||
};
|
||||
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||
use openshell_core::proto::{GpuResourceRequirements, SandboxServiceExposure, ServiceEndpoint};
|
||||
use openshell_core::proto::{
|
||||
GpuResourceRequirements, SandboxServiceExposure, ServiceAuthorizationMode, ServiceEndpoint,
|
||||
};
|
||||
|
||||
// ---- shell_escape ----
|
||||
|
||||
@@ -6720,10 +6731,12 @@ mod tests {
|
||||
SandboxServiceExposure {
|
||||
service: String::new(),
|
||||
target_port: 4500,
|
||||
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
|
||||
},
|
||||
SandboxServiceExposure {
|
||||
service: "metrics".to_string(),
|
||||
target_port: 9090,
|
||||
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
@@ -6756,9 +6769,46 @@ mod tests {
|
||||
assert_eq!(endpoint.name, service);
|
||||
assert_eq!(endpoint.target_port, target_port);
|
||||
assert!(endpoint.domain);
|
||||
let expected_mode = if service.is_empty() {
|
||||
ServiceAuthorizationMode::Strip
|
||||
} else {
|
||||
ServiceAuthorizationMode::BearerPassthrough
|
||||
};
|
||||
assert_eq!(endpoint.authorization_mode(), expected_mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sandbox_rejects_unknown_service_authorization_mode_before_persisting() {
|
||||
let state = test_server_state().await;
|
||||
let error = handle_create_sandbox(
|
||||
&state,
|
||||
authed_request(CreateSandboxRequest {
|
||||
name: "invalid-service-authorization".to_string(),
|
||||
spec: Some(SandboxSpec::default()),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
service_exposures: vec![SandboxServiceExposure {
|
||||
service: String::new(),
|
||||
target_port: 4500,
|
||||
authorization_mode: 99,
|
||||
}],
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown service authorization mode should be rejected");
|
||||
|
||||
assert_eq!(error.code(), tonic::Code::InvalidArgument);
|
||||
assert!(
|
||||
state
|
||||
.store
|
||||
.get_message_by_name::<Sandbox>("default", "invalid-service-authorization")
|
||||
.await
|
||||
.expect("sandbox lookup should succeed")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_sandbox_begins_rollback_when_service_exposure_fails() {
|
||||
let state = test_server_state().await;
|
||||
@@ -6788,10 +6838,12 @@ mod tests {
|
||||
SandboxServiceExposure {
|
||||
service: "web".to_string(),
|
||||
target_port: 8080,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
},
|
||||
SandboxServiceExposure {
|
||||
service: "metrics".to_string(),
|
||||
target_port: 9090,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
@@ -6875,10 +6927,12 @@ mod tests {
|
||||
SandboxServiceExposure {
|
||||
service: "web".to_string(),
|
||||
target_port: 8080,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
},
|
||||
SandboxServiceExposure {
|
||||
service: "web".to_string(),
|
||||
target_port: 8081,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
|
||||
@@ -7,7 +7,8 @@ use std::sync::Arc;
|
||||
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||
use openshell_core::proto::{
|
||||
DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest,
|
||||
ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse,
|
||||
ListServicesRequest, ListServicesResponse, Sandbox, ServiceAuthorizationMode, ServiceEndpoint,
|
||||
ServiceEndpointResponse,
|
||||
};
|
||||
use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace};
|
||||
use prost::Message as _;
|
||||
@@ -49,19 +50,37 @@ pub(super) async fn handle_expose_service(
|
||||
super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace())
|
||||
.await?
|
||||
.ensure_active()?;
|
||||
validate_service_exposure_request(&req.name, req.target_port)?;
|
||||
expose_service_endpoint(state, &workspace, &sandbox, &req.name, req.target_port).await
|
||||
let authorization_mode =
|
||||
validate_service_exposure_request(&req.name, req.target_port, req.authorization_mode)?;
|
||||
expose_service_endpoint(
|
||||
state,
|
||||
&workspace,
|
||||
&sandbox,
|
||||
&req.name,
|
||||
req.target_port,
|
||||
authorization_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn validate_service_exposure_request(
|
||||
service: &str,
|
||||
target_port: u32,
|
||||
) -> Result<(), Status> {
|
||||
authorization_mode: i32,
|
||||
) -> Result<ServiceAuthorizationMode, Status> {
|
||||
validate_optional_endpoint_name("service", service, MAX_SERVICE_NAME_LEN)?;
|
||||
if target_port == 0 || target_port > u32::from(u16::MAX) {
|
||||
return Err(Status::invalid_argument("target_port must be in 1..=65535"));
|
||||
}
|
||||
Ok(())
|
||||
match ServiceAuthorizationMode::try_from(authorization_mode) {
|
||||
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) => {
|
||||
Ok(ServiceAuthorizationMode::Strip)
|
||||
}
|
||||
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
|
||||
Ok(ServiceAuthorizationMode::BearerPassthrough)
|
||||
}
|
||||
Err(_) => Err(Status::invalid_argument("authorization_mode is invalid")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn expose_service_endpoint(
|
||||
@@ -70,6 +89,7 @@ pub(super) async fn expose_service_endpoint(
|
||||
sandbox: &Sandbox,
|
||||
service: &str,
|
||||
target_port: u32,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
) -> Result<Response<ServiceEndpointResponse>, Status> {
|
||||
let sandbox_name = sandbox.object_name();
|
||||
|
||||
@@ -132,6 +152,7 @@ pub(super) async fn expose_service_endpoint(
|
||||
name: service.to_string(),
|
||||
target_port,
|
||||
domain: true,
|
||||
authorization_mode: authorization_mode as i32,
|
||||
};
|
||||
|
||||
// Single-attempt CAS write: fails with ABORTED on concurrent modification
|
||||
@@ -344,8 +365,16 @@ async fn get_service_endpoint(
|
||||
|
||||
fn service_endpoint_response(
|
||||
state: &Arc<ServerState>,
|
||||
endpoint: ServiceEndpoint,
|
||||
mut endpoint: ServiceEndpoint,
|
||||
) -> ServiceEndpointResponse {
|
||||
endpoint.authorization_mode =
|
||||
match ServiceAuthorizationMode::try_from(endpoint.authorization_mode) {
|
||||
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
|
||||
ServiceAuthorizationMode::BearerPassthrough as i32
|
||||
}
|
||||
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip)
|
||||
| Err(_) => ServiceAuthorizationMode::Strip as i32,
|
||||
};
|
||||
let workspace = endpoint.object_workspace();
|
||||
let url =
|
||||
service_routing::endpoint_url(&state.config, workspace, &endpoint.sandbox, &endpoint.name)
|
||||
@@ -456,6 +485,100 @@ mod tests {
|
||||
assert!(validate_endpoint_name("service", "Web", 28).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_mode_defaults_to_strip_and_rejects_unknown_values() {
|
||||
assert_eq!(
|
||||
validate_service_exposure_request("web", 8080, 0).unwrap(),
|
||||
ServiceAuthorizationMode::Strip
|
||||
);
|
||||
assert_eq!(
|
||||
validate_service_exposure_request(
|
||||
"web",
|
||||
8080,
|
||||
ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||
)
|
||||
.unwrap(),
|
||||
ServiceAuthorizationMode::BearerPassthrough
|
||||
);
|
||||
assert_eq!(
|
||||
validate_service_exposure_request("web", 8080, 99)
|
||||
.unwrap_err()
|
||||
.code(),
|
||||
tonic::Code::InvalidArgument
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_authorization_mode_is_rejected_before_persistence() {
|
||||
let state = test_server_state().await;
|
||||
seed_sandbox(&state, "my-sandbox").await;
|
||||
|
||||
let error = handle_expose_service(
|
||||
&state,
|
||||
authed_request(ExposeServiceRequest {
|
||||
sandbox: "my-sandbox".to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
authorization_mode: 99,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.code(), tonic::Code::InvalidArgument);
|
||||
assert!(
|
||||
get_service_endpoint(&state, "default", "my-sandbox", "web")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_unspecified_authorization_mode_is_reported_as_strip() {
|
||||
let state = test_server_state().await;
|
||||
seed_sandbox(&state, "my-sandbox").await;
|
||||
|
||||
handle_expose_service(
|
||||
&state,
|
||||
authed_request(ExposeServiceRequest {
|
||||
sandbox: "my-sandbox".to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stored = get_service_endpoint(&state, "default", "my-sandbox", "web")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
stored.authorization_mode = ServiceAuthorizationMode::Unspecified as i32;
|
||||
state.store.put_message(&stored).await.unwrap();
|
||||
|
||||
let response = handle_get_service(
|
||||
&state,
|
||||
authed_request(GetServiceRequest {
|
||||
sandbox: "my-sandbox".to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: "web".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(
|
||||
response.endpoint.unwrap().authorization_mode(),
|
||||
ServiceAuthorizationMode::Strip
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_lifecycle_round_trip() {
|
||||
let state = test_server_state().await;
|
||||
@@ -472,12 +595,17 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert_eq!(exposed.endpoint.as_ref().unwrap().target_port, 8080);
|
||||
assert_eq!(
|
||||
exposed.endpoint.as_ref().unwrap().authorization_mode(),
|
||||
ServiceAuthorizationMode::Strip
|
||||
);
|
||||
|
||||
let listed = handle_list_services(
|
||||
&state,
|
||||
@@ -511,6 +639,26 @@ mod tests {
|
||||
.into_inner();
|
||||
assert_eq!(fetched.endpoint.as_ref().unwrap().target_port, 8080);
|
||||
|
||||
let updated = handle_expose_service(
|
||||
&state,
|
||||
authed_request(ExposeServiceRequest {
|
||||
sandbox: "my-sandbox".to_string(),
|
||||
workspace_scope: Some(openshell_core::proto::workspace_selector("default")),
|
||||
name: "web".to_string(),
|
||||
target_port: 9090,
|
||||
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
assert_eq!(updated.endpoint.as_ref().unwrap().target_port, 9090);
|
||||
assert_eq!(
|
||||
updated.endpoint.as_ref().unwrap().authorization_mode(),
|
||||
ServiceAuthorizationMode::BearerPassthrough
|
||||
);
|
||||
|
||||
let deleted = handle_delete_service(
|
||||
&state,
|
||||
authed_request(DeleteServiceRequest {
|
||||
@@ -643,6 +791,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -661,6 +810,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 9090,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -713,6 +863,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 7070,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -732,6 +883,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -750,6 +902,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 9090,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -840,6 +993,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -856,6 +1010,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 9090,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -999,6 +1154,7 @@ mod tests {
|
||||
name: "api".to_string(),
|
||||
target_port: 3000,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -11,7 +11,9 @@ use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header
|
||||
use hyper_util::rt::TokioIo;
|
||||
use openshell_core::ObjectId;
|
||||
use openshell_core::config::ServiceRoutingConfig;
|
||||
use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open};
|
||||
use openshell_core::proto::{
|
||||
Sandbox, SandboxPhase, ServiceAuthorizationMode, ServiceEndpoint, TcpRelayTarget, relay_open,
|
||||
};
|
||||
use openshell_ocsf::{
|
||||
ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, EventContext,
|
||||
HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder,
|
||||
@@ -428,6 +430,19 @@ async fn proxy_to_endpoint(
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
let authorization_mode = effective_authorization_mode(endpoint.authorization_mode);
|
||||
if validate_application_authorization(&req, authorization_mode).is_err() {
|
||||
let err = ServiceRouteError::invalid_request();
|
||||
emit_service_http_failure(
|
||||
&state,
|
||||
&req,
|
||||
&sandbox_name,
|
||||
&service_name,
|
||||
Some(&endpoint),
|
||||
&err,
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let websocket_upgrade = is_websocket_upgrade(&req);
|
||||
let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut req));
|
||||
@@ -446,7 +461,7 @@ async fn proxy_to_endpoint(
|
||||
None => open_upstream(&state, &sandbox, &endpoint, target_port, websocket_upgrade).await?,
|
||||
};
|
||||
|
||||
let upstream = build_upstream_request(req, target_port, websocket_upgrade)?;
|
||||
let upstream = build_upstream_request(req, target_port, websocket_upgrade, authorization_mode)?;
|
||||
let replay = reused.then(|| replayable_request(&upstream)).flatten();
|
||||
let first_attempt = if reused {
|
||||
sender.try_send_request(upstream).await.map_err(|mut err| {
|
||||
@@ -626,6 +641,7 @@ fn build_upstream_request(
|
||||
req: Request<Body>,
|
||||
target_port: u16,
|
||||
preserve_upgrade_headers: bool,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
) -> Result<Request<Body>, ServiceRouteError> {
|
||||
let (parts, body) = req.into_parts();
|
||||
let path = parts.uri.path_and_query().map_or("/", |path| path.as_str());
|
||||
@@ -644,7 +660,7 @@ fn build_upstream_request(
|
||||
for (name, value) in &parts.headers {
|
||||
if (is_hop_by_hop_header(name)
|
||||
&& !(preserve_upgrade_headers && is_websocket_hop_by_hop_header(name)))
|
||||
|| is_gateway_auth_header(name)
|
||||
|| is_gateway_auth_header(name, authorization_mode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -724,15 +740,83 @@ fn is_websocket_hop_by_hop_header(name: &header::HeaderName) -> bool {
|
||||
matches!(name.as_str(), "connection" | "upgrade")
|
||||
}
|
||||
|
||||
fn is_gateway_auth_header(name: &header::HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"authorization"
|
||||
| "cf-access-jwt-assertion"
|
||||
| "x-forwarded-client-cert"
|
||||
| "x-ssl-client-cert"
|
||||
| "x-client-cert"
|
||||
)
|
||||
pub fn effective_authorization_mode(value: i32) -> ServiceAuthorizationMode {
|
||||
match ServiceAuthorizationMode::try_from(value) {
|
||||
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
|
||||
ServiceAuthorizationMode::BearerPassthrough
|
||||
}
|
||||
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) | Err(_) => {
|
||||
ServiceAuthorizationMode::Strip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authorization_mode_label(value: i32) -> &'static str {
|
||||
match effective_authorization_mode(value) {
|
||||
ServiceAuthorizationMode::BearerPassthrough => "bearer_passthrough",
|
||||
ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip => "strip",
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_application_authorization<B>(
|
||||
req: &Request<B>,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
) -> Result<(), ServiceRouteError> {
|
||||
if authorization_mode != ServiceAuthorizationMode::BearerPassthrough {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut values = req.headers().get_all(header::AUTHORIZATION).iter();
|
||||
let Some(value) = values.next() else {
|
||||
return Ok(());
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return Err(ServiceRouteError::invalid_request());
|
||||
}
|
||||
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ServiceRouteError::invalid_request())?;
|
||||
let Some((scheme, credential)) = value.split_once(' ') else {
|
||||
return Err(ServiceRouteError::invalid_request());
|
||||
};
|
||||
let credential = credential.trim_start_matches(' ');
|
||||
if !scheme.eq_ignore_ascii_case("bearer") || !is_bearer_token68(credential) {
|
||||
return Err(ServiceRouteError::invalid_request());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_bearer_token68(value: &str) -> bool {
|
||||
let mut saw_data = false;
|
||||
let mut saw_padding = false;
|
||||
for byte in value.bytes() {
|
||||
if byte == b'=' {
|
||||
saw_padding = true;
|
||||
} else if !saw_padding
|
||||
&& (byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/'))
|
||||
{
|
||||
saw_data = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
saw_data
|
||||
}
|
||||
|
||||
fn is_gateway_auth_header(
|
||||
name: &header::HeaderName,
|
||||
authorization_mode: ServiceAuthorizationMode,
|
||||
) -> bool {
|
||||
match name.as_str() {
|
||||
"authorization" => authorization_mode != ServiceAuthorizationMode::BearerPassthrough,
|
||||
"cf-access-jwt-assertion"
|
||||
| "x-forwarded-client-cert"
|
||||
| "x-ssl-client-cert"
|
||||
| "x-client-cert" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_cookie_header(value: &HeaderValue) -> Option<HeaderValue> {
|
||||
@@ -830,7 +914,11 @@ fn build_service_endpoint_config_event(
|
||||
))
|
||||
.unmapped("endpoint_name", endpoint_name(endpoint))
|
||||
.unmapped("service_name", endpoint.name.clone())
|
||||
.unmapped("target_port", u64::from(endpoint.target_port));
|
||||
.unmapped("target_port", u64::from(endpoint.target_port))
|
||||
.unmapped(
|
||||
"authorization_mode",
|
||||
authorization_mode_label(endpoint.authorization_mode),
|
||||
);
|
||||
|
||||
if !url.is_empty() {
|
||||
builder = builder.unmapped("url", url.to_string());
|
||||
@@ -849,6 +937,10 @@ fn build_service_endpoint_delete_event(endpoint: &ServiceEndpoint) -> OcsfEvent
|
||||
.unmapped("endpoint_name", endpoint_name(endpoint))
|
||||
.unmapped("service_name", endpoint.name.clone())
|
||||
.unmapped("target_port", u64::from(endpoint.target_port))
|
||||
.unmapped(
|
||||
"authorization_mode",
|
||||
authorization_mode_label(endpoint.authorization_mode),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -990,6 +1082,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,6 +1326,7 @@ mod tests {
|
||||
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
|
||||
assert_eq!(json["unmapped"]["service_name"], "web");
|
||||
assert_eq!(json["unmapped"]["target_port"], 8080);
|
||||
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
|
||||
assert!(
|
||||
event
|
||||
.format_shorthand()
|
||||
@@ -1249,6 +1343,7 @@ mod tests {
|
||||
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
|
||||
assert_eq!(json["unmapped"]["service_name"], "web");
|
||||
assert_eq!(json["unmapped"]["target_port"], 8080);
|
||||
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
|
||||
assert!(
|
||||
event
|
||||
.format_shorthand()
|
||||
@@ -1309,7 +1404,8 @@ mod tests {
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let upstream = build_upstream_request(request, 8080, false).unwrap();
|
||||
let upstream =
|
||||
build_upstream_request(request, 8080, false, ServiceAuthorizationMode::Strip).unwrap();
|
||||
|
||||
assert_eq!(upstream.uri(), "/path");
|
||||
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
|
||||
@@ -1322,6 +1418,145 @@ mod tests {
|
||||
assert_eq!(upstream.headers()["x-app-header"], "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_endpoint_authorization_mode_strips_authorization() {
|
||||
let request = Request::builder()
|
||||
.uri("/path")
|
||||
.header(header::AUTHORIZATION, "Bearer application-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let mode = effective_authorization_mode(ServiceAuthorizationMode::Unspecified as i32);
|
||||
validate_application_authorization(&request, mode).unwrap();
|
||||
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
|
||||
|
||||
assert_eq!(mode, ServiceAuthorizationMode::Strip);
|
||||
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_passthrough_preserves_valid_authorization_and_strips_gateway_identity() {
|
||||
let request = Request::builder()
|
||||
.uri("/path")
|
||||
.header(header::AUTHORIZATION, "bEaReR application-token")
|
||||
.header("cf-access-jwt-assertion", "edge-token")
|
||||
.header("x-forwarded-client-cert", "cert")
|
||||
.header(header::PROXY_AUTHORIZATION, "Basic proxy-secret")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
"theme=dark; CF_Authorization=edge-cookie; app=session",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let mode = ServiceAuthorizationMode::BearerPassthrough;
|
||||
validate_application_authorization(&request, mode).unwrap();
|
||||
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
upstream.headers()[header::AUTHORIZATION],
|
||||
"bEaReR application-token"
|
||||
);
|
||||
assert!(!upstream.headers().contains_key("cf-access-jwt-assertion"));
|
||||
assert!(!upstream.headers().contains_key("x-forwarded-client-cert"));
|
||||
assert!(!upstream.headers().contains_key(header::PROXY_AUTHORIZATION));
|
||||
assert_eq!(
|
||||
upstream.headers()[header::COOKIE],
|
||||
"theme=dark; app=session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_passthrough_allows_missing_authorization() {
|
||||
let request = Request::builder().uri("/path").body(Body::empty()).unwrap();
|
||||
|
||||
let mode = ServiceAuthorizationMode::BearerPassthrough;
|
||||
validate_application_authorization(&request, mode).unwrap();
|
||||
let upstream = build_upstream_request(request, 8080, false, mode).unwrap();
|
||||
|
||||
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_passthrough_rejects_ambiguous_or_malformed_authorization() {
|
||||
for value in [
|
||||
"",
|
||||
"Bearer",
|
||||
"Bearer ",
|
||||
"Basic abc",
|
||||
"Bearer abc extra",
|
||||
"Bearer abc,def",
|
||||
"Bearer abc=def",
|
||||
"Bearer\tabc",
|
||||
" Bearer abc",
|
||||
] {
|
||||
let request = Request::builder()
|
||||
.uri("/path")
|
||||
.header(header::AUTHORIZATION, value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(
|
||||
validate_application_authorization(
|
||||
&request,
|
||||
ServiceAuthorizationMode::BearerPassthrough,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
for value in ["Bearer abc", "bearer abc-._~+/==", "Bearer abc"] {
|
||||
let request = Request::builder()
|
||||
.uri("/path")
|
||||
.header(header::AUTHORIZATION, value)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
validate_application_authorization(
|
||||
&request,
|
||||
ServiceAuthorizationMode::BearerPassthrough,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut request = Request::builder().uri("/path").body(Body::empty()).unwrap();
|
||||
request.headers_mut().append(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer first"),
|
||||
);
|
||||
request.headers_mut().append(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer second"),
|
||||
);
|
||||
assert!(
|
||||
validate_application_authorization(
|
||||
&request,
|
||||
ServiceAuthorizationMode::BearerPassthrough,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_value_is_not_in_service_routing_events() {
|
||||
const SENTINEL: &str = "never-log-this-capability";
|
||||
let request = Request::builder()
|
||||
.uri("/private")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {SENTINEL}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let err = ServiceRouteError::invalid_request();
|
||||
let event = build_service_http_failure_event(
|
||||
18080,
|
||||
&request,
|
||||
"my-sandbox",
|
||||
"web",
|
||||
Some(&endpoint()),
|
||||
&err,
|
||||
);
|
||||
|
||||
assert!(!event.to_json().unwrap().to_string().contains(SENTINEL));
|
||||
assert!(!event.format_shorthand().contains(SENTINEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_websocket_upgrade_request() {
|
||||
let request = Request::builder()
|
||||
@@ -1346,7 +1581,8 @@ mod tests {
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let upstream = build_upstream_request(request, 8080, true).unwrap();
|
||||
let upstream =
|
||||
build_upstream_request(request, 8080, true, ServiceAuthorizationMode::Strip).unwrap();
|
||||
|
||||
assert_eq!(upstream.uri(), "/chat?session=main");
|
||||
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
|
||||
@@ -1355,6 +1591,30 @@ mod tests {
|
||||
assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_passthrough_preserves_authorization_on_websocket_upgrade() {
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/chat")
|
||||
.header(header::CONNECTION, "Upgrade")
|
||||
.header(header::UPGRADE, "websocket")
|
||||
.header("sec-websocket-key", "abc")
|
||||
.header(header::AUTHORIZATION, "Bearer application-token")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let mode = ServiceAuthorizationMode::BearerPassthrough;
|
||||
validate_application_authorization(&request, mode).unwrap();
|
||||
let upstream = build_upstream_request(request, 8080, true, mode).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
upstream.headers()[header::AUTHORIZATION],
|
||||
"Bearer application-token"
|
||||
);
|
||||
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
|
||||
assert_eq!(upstream.headers()[header::UPGRADE], "websocket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_endpoint_uses_workspace_for_lookup() {
|
||||
let store = crate::persistence::test_store().await;
|
||||
@@ -1375,6 +1635,7 @@ mod tests {
|
||||
name: "web".to_string(),
|
||||
target_port: 8080,
|
||||
domain: true,
|
||||
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||
};
|
||||
store.put_message(&ep).await.unwrap();
|
||||
|
||||
|
||||
@@ -126,15 +126,19 @@ mod tests {
|
||||
// inventories; the provider-environment file map is public-only. The
|
||||
// request has no provider-file capability field: older supervisors ignore
|
||||
// the additive file map while retaining the rest of the response.
|
||||
// Service authorization also extends both schemas additively. Legacy
|
||||
// payloads retain the safe Strip default.
|
||||
const PUBLIC_RPC_SCHEMA_SHA256: &str =
|
||||
"2ed66dbc38c60eb96c7461c76d02813c177facfad93753b180534477270ad240";
|
||||
"2e156c6ad3c8eb51bcd30dc13b173fe339b38207a1b1f98f7be2e0cad8e3bd45";
|
||||
const DURABLE_SCHEMA_SHA256: &str =
|
||||
"399737f2a367d2e3a9d78cf84e2a97eef041835554599790788e4bbf318116c3";
|
||||
"38165d9d76f49fcfe98a12f241e032838a2376c1d1a87ea2796fd33b9b1a3541";
|
||||
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
||||
"d3c444ecdb42306af8a81791481fdfc147ddc54bf344e1c8e69bd06745c6cc3c";
|
||||
"761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3";
|
||||
// A persisted Sandbox without endpoint status retains its lifecycle fields;
|
||||
// the absent repeated field decodes empty and needs no database rewrite.
|
||||
const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964";
|
||||
// ServiceEndpoint encoded before authorization_mode field 7 existed.
|
||||
const SERVICE_ENDPOINT_WITHOUT_AUTHORIZATION_MODE: &str = "0a260a0b656e64706f696e742d6964120c73616e64626f782d2d77656228073a0764656661756c74120a73616e64626f782d69641a0773616e64626f78220377656228903f3001";
|
||||
// Synthetic payloads generated with the public declarations at v0.0.116,
|
||||
// before their relocation into openshell.storage.v1. Values are deliberately
|
||||
// non-secret and the ordinary protobuf bytes contain no package names.
|
||||
@@ -594,9 +598,9 @@ mod tests {
|
||||
overlap_hash.as_str(),
|
||||
),
|
||||
(
|
||||
(306, 26),
|
||||
(93, 20),
|
||||
(81, 20),
|
||||
(306, 27),
|
||||
(93, 21),
|
||||
(81, 21),
|
||||
PUBLIC_RPC_SCHEMA_SHA256,
|
||||
DURABLE_SCHEMA_SHA256,
|
||||
PUBLIC_DURABLE_OVERLAP_SHA256
|
||||
@@ -605,6 +609,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_endpoint_without_authorization_mode_decodes_as_strip() {
|
||||
use openshell_core::proto::{ServiceAuthorizationMode, ServiceEndpoint};
|
||||
|
||||
let endpoint = ServiceEndpoint::decode(
|
||||
legacy_bytes(SERVICE_ENDPOINT_WITHOUT_AUTHORIZATION_MODE).as_slice(),
|
||||
)
|
||||
.expect("stored service endpoint without authorization mode must decode");
|
||||
|
||||
assert_eq!(endpoint.sandbox_id, "sandbox-id");
|
||||
assert_eq!(endpoint.sandbox, "sandbox");
|
||||
assert_eq!(endpoint.name, "web");
|
||||
assert_eq!(endpoint.target_port, 8080);
|
||||
assert!(endpoint.domain);
|
||||
assert_eq!(
|
||||
endpoint.authorization_mode(),
|
||||
ServiceAuthorizationMode::Unspecified
|
||||
);
|
||||
assert_eq!(
|
||||
crate::service_routing::effective_authorization_mode(endpoint.authorization_mode),
|
||||
ServiceAuthorizationMode::Strip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_readiness_sandbox_spec_decodes_with_initial_attachment_epoch() {
|
||||
let spec = openshell_core::proto::SandboxSpec::decode(
|
||||
|
||||
@@ -478,13 +478,16 @@ name selects the unnamed endpoint. The returned sandbox includes a service URL
|
||||
map keyed by those names; use the empty key for the unnamed endpoint:
|
||||
|
||||
```python
|
||||
from openshell import SandboxClient, ServiceExposure
|
||||
from openshell import SandboxClient, ServiceAuthorizationMode, ServiceExposure
|
||||
|
||||
with SandboxClient.from_active_cluster() as client:
|
||||
sandbox = client.create(
|
||||
workspace="default",
|
||||
name="app-server",
|
||||
service_exposures=[ServiceExposure(target_port=4500)],
|
||||
service_exposures=[ServiceExposure(
|
||||
target_port=4500,
|
||||
authorization_mode=ServiceAuthorizationMode.BEARER_PASSTHROUGH,
|
||||
)],
|
||||
)
|
||||
print(sandbox.service_urls[""])
|
||||
```
|
||||
@@ -493,7 +496,10 @@ with SandboxClient.from_active_cluster() as client:
|
||||
const sandbox = await client.sandbox.create({
|
||||
name: 'app-server',
|
||||
image: 'base',
|
||||
serviceExposures: [{ targetPort: 4500 }],
|
||||
serviceExposures: [{
|
||||
targetPort: 4500,
|
||||
authorizationMode: ServiceAuthorizationMode.BearerPassthrough,
|
||||
}],
|
||||
})
|
||||
console.log(sandbox.serviceUrls[''])
|
||||
```
|
||||
@@ -502,7 +508,10 @@ console.log(sandbox.serviceUrls[''])
|
||||
sandbox, err := client.Sandboxes().Create(
|
||||
ctx, "default", "app-server", spec, nil,
|
||||
v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{
|
||||
{TargetPort: 4500},
|
||||
{
|
||||
TargetPort: 4500,
|
||||
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||
},
|
||||
}},
|
||||
)
|
||||
fmt.Println(sandbox.ServiceURLs[""])
|
||||
@@ -514,6 +523,7 @@ let sandbox = client.create_sandbox(openshell_sdk::SandboxSpec {
|
||||
service_exposures: vec![openshell_sdk::ServiceExposure {
|
||||
service: String::new(),
|
||||
target_port: 4500,
|
||||
authorization_mode: openshell_sdk::ServiceAuthorizationMode::BearerPassthrough,
|
||||
}],
|
||||
..Default::default()
|
||||
}).await?;
|
||||
@@ -532,6 +542,63 @@ Pass an optional service name to create a named service URL:
|
||||
openshell service expose my-sandbox 8080 web
|
||||
```
|
||||
|
||||
OpenShell strips the incoming `Authorization` header by default. Opt a service
|
||||
into forwarding one valid bearer credential unchanged when the application
|
||||
performs its own authentication:
|
||||
|
||||
```shell
|
||||
openshell service expose my-sandbox 4500 \
|
||||
--authorization-mode bearer-passthrough
|
||||
```
|
||||
|
||||
For a create-time exposure, use both flags:
|
||||
|
||||
```shell
|
||||
openshell sandbox create \
|
||||
--expose 4500 \
|
||||
--expose-authorization-mode bearer-passthrough \
|
||||
--detach \
|
||||
-- ./authenticated-server
|
||||
```
|
||||
|
||||
In `bearer-passthrough` mode, OpenShell accepts zero or one application
|
||||
`Authorization` header. When present, it must contain a nonempty Bearer
|
||||
credential. Missing credentials reach the application so it can return its own
|
||||
authentication response. Duplicate, Basic, and malformed credentials fail with
|
||||
`400 Bad Request`. Gateway and edge identity headers, proxy authorization, and
|
||||
edge authentication cookies remain stripped.
|
||||
|
||||
<Warning>
|
||||
Bearer passthrough delivers the caller's credential to the sandbox service.
|
||||
Enable it only when that service is trusted to receive the credential. Exposed
|
||||
services still use the gateway listener and its TLS configuration in this
|
||||
release.
|
||||
</Warning>
|
||||
|
||||
For example, Codex App Server can keep the raw capability outside the sandbox
|
||||
and receive only its SHA-256 verifier:
|
||||
|
||||
```shell
|
||||
APP_SERVER_TOKEN="$(openssl rand -hex 32)"
|
||||
APP_SERVER_TOKEN_SHA256="$(printf %s "$APP_SERVER_TOKEN" | openssl dgst -sha256 -hex | awk '{print $2}')"
|
||||
|
||||
openshell sandbox create \
|
||||
--name codex-server \
|
||||
--env "APP_SERVER_TOKEN_SHA256=$APP_SERVER_TOKEN_SHA256" \
|
||||
--expose 4500 \
|
||||
--expose-authorization-mode bearer-passthrough \
|
||||
--detach \
|
||||
-- codex app-server \
|
||||
--listen ws://127.0.0.1:4500 \
|
||||
--ws-auth capability-token \
|
||||
--ws-token-sha256 "$APP_SERVER_TOKEN_SHA256"
|
||||
```
|
||||
|
||||
Clients send `Authorization: Bearer $APP_SERVER_TOKEN` in the WebSocket
|
||||
handshake. Codex's WebSocket transport is experimental and unsupported for
|
||||
production workloads. It authenticates the handshake before the app-server
|
||||
`initialize` request.
|
||||
|
||||
List exposed endpoints:
|
||||
|
||||
```shell
|
||||
@@ -552,7 +619,8 @@ openshell service list my-sandbox --output yaml
|
||||
```
|
||||
|
||||
Structured list output contains `services` and `next_page_token` fields. Each
|
||||
record contains `workspace`, `sandbox`, `service`, `target_port`, and `url`.
|
||||
record contains `workspace`, `sandbox`, `service`, `target_port`,
|
||||
`authorization_mode`, and `url`.
|
||||
The unnamed service uses an empty `service` string. Pass the returned token to
|
||||
`--page-token` to continue. An empty result has an empty `services` collection.
|
||||
|
||||
@@ -571,7 +639,14 @@ openshell service delete my-sandbox
|
||||
```
|
||||
|
||||
<Note>
|
||||
Loopback gateways return local `openshell.localhost` URLs. Remote gateways return HTTPS URLs that require normal gateway authentication. For gateway service-domain configuration, refer to [Manage Gateways](/how-it-works/gateways/overview#configure-service-forwarding).
|
||||
Loopback gateways return local `openshell.localhost` URLs. Remote gateways
|
||||
return HTTPS URLs on the gateway listener. Service routes bypass control-plane
|
||||
RPC authorization and do not use OIDC or CLI login. Because they share the
|
||||
listener in this release, its TLS configuration—including any required client
|
||||
certificate—still applies. The application is responsible for authentication
|
||||
enabled on its endpoint, and an upstream edge proxy may still apply its own
|
||||
access policy. For gateway service-domain configuration, refer to
|
||||
[Manage Gateways](/how-it-works/gateways/overview#configure-service-forwarding).
|
||||
</Note>
|
||||
|
||||
## Monitor and Debug
|
||||
|
||||
Generated
+164
-7
@@ -38,7 +38,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -814,7 +814,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -902,6 +902,8 @@ dependencies = [
|
||||
"noyalib",
|
||||
"prost",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
@@ -909,6 +911,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
@@ -1111,6 +1114,20 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -1127,7 +1144,52 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1317,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1326,6 +1388,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -1375,7 +1443,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1452,7 +1520,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1466,6 +1534,16 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -1617,6 +1695,12 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1738,6 +1822,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1747,6 +1840,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
@@ -63,6 +63,11 @@ name = "custom_image"
|
||||
path = "tests/custom_image.rs"
|
||||
required-features = ["e2e-docker"]
|
||||
|
||||
[[test]]
|
||||
name = "service_bearer_passthrough"
|
||||
path = "tests/service_bearer_passthrough.rs"
|
||||
required-features = ["e2e-docker"]
|
||||
|
||||
[[test]]
|
||||
name = "rootfs_tar"
|
||||
path = "tests/rootfs_tar.rs"
|
||||
@@ -240,11 +245,14 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
rand = "0.9"
|
||||
rustls = { version = "0.23", default-features = false, features = ["std", "logging", "tls12", "aws_lc_rs"] }
|
||||
rustls-pemfile = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
|
||||
tonic = { version = "0.14", features = ["transport"] }
|
||||
tonic-prost = "0.14"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "aws_lc_rs"] }
|
||||
tower = "0.5"
|
||||
url = "2"
|
||||
nix = { version = "0.29", features = ["process", "signal", "term", "user"] }
|
||||
|
||||
@@ -477,9 +477,12 @@ impl Backend {
|
||||
async fn spawn(&mut self, base: &str, tls_directory: &Path) -> Result<String, String> {
|
||||
let tls_directory = tls_directory
|
||||
.to_str()
|
||||
.filter(|path| !path.contains([',', '\n', '\r']))
|
||||
.filter(|path| !path.contains([':', '\n', '\r']))
|
||||
.ok_or("fixture TLS mount path is invalid")?;
|
||||
let mount = format!("type=bind,src={tls_directory},dst=/fixture-tls,readonly");
|
||||
// Docker's structured `--mount` syntax cannot request SELinux
|
||||
// relabeling. Both fixture backends mount this ephemeral directory, so
|
||||
// use the shared `z` label rather than the single-container `Z` label.
|
||||
let mount = format!("{tls_directory}:/fixture-tls:ro,z");
|
||||
let namespace_label = format!("openshell.ai/sandbox-namespace={}", self.namespace);
|
||||
let mut command = Command::from(self.engine.command());
|
||||
command
|
||||
@@ -501,7 +504,7 @@ impl Backend {
|
||||
"--read-only",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--mount",
|
||||
"--volume",
|
||||
&mount,
|
||||
"--entrypoint",
|
||||
"/usr/bin/python3",
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "e2e-docker")]
|
||||
|
||||
//! Verifies application bearer authorization behavior through an exposed
|
||||
//! `OpenShell` service.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt as _, Empty};
|
||||
use hyper::client::conn::http1;
|
||||
use hyper::{Request, StatusCode, header};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use openshell_e2e::harness::binary::openshell_cmd;
|
||||
use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, timeout};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use url::Position;
|
||||
|
||||
const SERVICE_PORT: &str = "4500";
|
||||
const BEARER_TOKEN: &str = "Bearer openshell-e2e-application-token";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const HEADER_ECHO_SERVER: &str = r#"
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = self.headers.get("Authorization", "").encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
pass
|
||||
|
||||
ThreadingHTTPServer(("127.0.0.1", 4500), Handler).serve_forever()
|
||||
"#;
|
||||
|
||||
async fn run_cli(args: &[&str]) -> Result<std::process::Output, String> {
|
||||
let mut command = openshell_cmd();
|
||||
command
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| format!("failed to run openshell: {error}"))
|
||||
}
|
||||
|
||||
enum ServiceTransport {
|
||||
Http,
|
||||
Https {
|
||||
connector: TlsConnector,
|
||||
server_name: ServerName<'static>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ServiceTarget {
|
||||
port: u16,
|
||||
authority: String,
|
||||
path: String,
|
||||
transport: ServiceTransport,
|
||||
}
|
||||
|
||||
impl ServiceTarget {
|
||||
fn from_url(url: &str) -> Result<Self, String> {
|
||||
let url = url::Url::parse(url).map_err(|error| format!("invalid service URL: {error}"))?;
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "service URL omitted its host".to_string())?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| "service URL omitted its port".to_string())?;
|
||||
let transport = match url.scheme() {
|
||||
"http" => ServiceTransport::Http,
|
||||
"https" => ServiceTransport::Https {
|
||||
connector: e2e_tls_connector()?,
|
||||
server_name: ServerName::try_from(host.to_string())
|
||||
.map_err(|error| format!("invalid service TLS server name: {error}"))?,
|
||||
},
|
||||
scheme => return Err(format!("unsupported service URL scheme {scheme:?}")),
|
||||
};
|
||||
let authority = url[Position::BeforeHost..Position::AfterPort].to_string();
|
||||
let path = url.query().map_or_else(
|
||||
|| url.path().to_string(),
|
||||
|query| format!("{}?{query}", url.path()),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
authority,
|
||||
path,
|
||||
transport,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn e2e_mtls_dir() -> Result<PathBuf, String> {
|
||||
let config_home = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.ok_or_else(|| "XDG_CONFIG_HOME is required for an HTTPS service URL".to_string())?;
|
||||
let gateway = std::env::var("OPENSHELL_GATEWAY")
|
||||
.map_err(|_| "OPENSHELL_GATEWAY is required for an HTTPS service URL".to_string())?;
|
||||
Ok(PathBuf::from(config_home)
|
||||
.join("openshell/gateways")
|
||||
.join(gateway)
|
||||
.join("mtls"))
|
||||
}
|
||||
|
||||
fn load_certificates(
|
||||
path: &Path,
|
||||
description: &str,
|
||||
) -> Result<Vec<CertificateDer<'static>>, String> {
|
||||
let file = File::open(path)
|
||||
.map_err(|error| format!("open {description} '{}': {error}", path.display()))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("parse {description} '{}': {error}", path.display()))
|
||||
}
|
||||
|
||||
fn load_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, String> {
|
||||
let file = File::open(path)
|
||||
.map_err(|error| format!("open client TLS key '{}': {error}", path.display()))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
rustls_pemfile::private_key(&mut reader)
|
||||
.map_err(|error| format!("parse client TLS key '{}': {error}", path.display()))?
|
||||
.ok_or_else(|| format!("client TLS key '{}' is empty", path.display()))
|
||||
}
|
||||
|
||||
fn e2e_tls_connector() -> Result<TlsConnector, String> {
|
||||
let mtls_dir = e2e_mtls_dir()?;
|
||||
let ca_path = mtls_dir.join("ca.crt");
|
||||
let mut roots = RootCertStore::empty();
|
||||
for certificate in load_certificates(&ca_path, "gateway CA certificate")? {
|
||||
roots.add(certificate).map_err(|error| {
|
||||
format!(
|
||||
"add gateway CA certificate '{}': {error}",
|
||||
ca_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let client_certificates =
|
||||
load_certificates(&mtls_dir.join("tls.crt"), "client TLS certificate")?;
|
||||
let client_key = load_private_key(&mtls_dir.join("tls.key"))?;
|
||||
let config = ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_client_auth_cert(client_certificates, client_key)
|
||||
.map_err(|error| format!("build e2e mTLS client configuration: {error}"))?;
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
async fn request_over_stream<S>(
|
||||
stream: S,
|
||||
target: &ServiceTarget,
|
||||
authorization: &str,
|
||||
) -> Result<(StatusCode, String), String>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let (mut sender, connection) = http1::handshake(TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(|error| format!("start HTTP connection: {error}"))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = connection.await;
|
||||
});
|
||||
|
||||
let request = Request::builder()
|
||||
.uri(&target.path)
|
||||
.header(header::HOST, &target.authority)
|
||||
.header(header::AUTHORIZATION, authorization)
|
||||
.body(Empty::<Bytes>::new())
|
||||
.map_err(|error| format!("build service request: {error}"))?;
|
||||
let response = sender
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|error| format!("send service request: {error}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| format!("read service response: {error}"))?
|
||||
.to_bytes();
|
||||
let body = String::from_utf8(body.to_vec())
|
||||
.map_err(|error| format!("service returned non-UTF-8 data: {error}"))?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
async fn request_service(
|
||||
target: &ServiceTarget,
|
||||
authorization: &str,
|
||||
) -> Result<(StatusCode, String), String> {
|
||||
// The service hostname is a virtual routing authority. Dial the loopback
|
||||
// gateway directly so the test does not depend on the host resolver
|
||||
// recognizing arbitrary subdomains of `.localhost`.
|
||||
let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, target.port))
|
||||
.await
|
||||
.map_err(|error| format!("connect to loopback service gateway: {error}"))?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
match &target.transport {
|
||||
ServiceTransport::Http => request_over_stream(stream, target, authorization).await,
|
||||
ServiceTransport::Https {
|
||||
connector,
|
||||
server_name,
|
||||
} => {
|
||||
let stream = connector
|
||||
.connect(server_name.clone(), stream)
|
||||
.await
|
||||
.map_err(|error| format!("start service TLS connection: {error}"))?;
|
||||
request_over_stream(stream, target, authorization).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_authorization(url: &str, expected: &str) -> Result<(), String> {
|
||||
// Parse the URL and load TLS material before the retry loop so permanent
|
||||
// test-configuration errors fail immediately instead of looking like
|
||||
// service-readiness timeouts.
|
||||
let target = ServiceTarget::from_url(url)?;
|
||||
let mut last_observation = "no request attempted".to_string();
|
||||
let result = timeout(READY_TIMEOUT, async {
|
||||
loop {
|
||||
match request_service(&target, BEARER_TOKEN).await {
|
||||
Ok((StatusCode::OK, body)) if body == expected => return Ok(()),
|
||||
Ok((StatusCode::OK, body)) => {
|
||||
return Err(format!(
|
||||
"service received unexpected Authorization value: {body:?}"
|
||||
));
|
||||
}
|
||||
Ok((status, body))
|
||||
if matches!(
|
||||
status,
|
||||
StatusCode::BAD_GATEWAY
|
||||
| StatusCode::PRECONDITION_FAILED
|
||||
| StatusCode::SERVICE_UNAVAILABLE
|
||||
) =>
|
||||
{
|
||||
last_observation =
|
||||
format!("service returned retryable status {status} with body {body:?}");
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
Err(error) => {
|
||||
last_observation = error;
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
Ok((status, body)) => {
|
||||
return Err(format!(
|
||||
"service returned unexpected status {status} with body {body:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!(
|
||||
"timed out waiting for the exposed service; last observation: {last_observation}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_bearer_passthrough_preserves_authorization_header() {
|
||||
let sandbox_name = format!("service-auth-{}", std::process::id());
|
||||
let create = run_cli(&[
|
||||
"sandbox",
|
||||
"create",
|
||||
"--name",
|
||||
&sandbox_name,
|
||||
"--from",
|
||||
E2E_WORKLOAD_IMAGE,
|
||||
"--expose",
|
||||
SERVICE_PORT,
|
||||
"--output",
|
||||
"json",
|
||||
"--detach",
|
||||
"--no-tty",
|
||||
"--",
|
||||
"python3",
|
||||
"-c",
|
||||
HEADER_ECHO_SERVER,
|
||||
])
|
||||
.await
|
||||
.expect("run sandbox create");
|
||||
assert!(
|
||||
create.status.success(),
|
||||
"sandbox create failed with exit {:?}: {}",
|
||||
create.status.code(),
|
||||
String::from_utf8_lossy(&create.stderr)
|
||||
);
|
||||
let mut sandbox = SandboxGuard::manage_existing(sandbox_name.clone());
|
||||
|
||||
let created: Value = serde_json::from_slice(&create.stdout).expect("parse sandbox create JSON");
|
||||
let service_url = created
|
||||
.get("service_urls")
|
||||
.and_then(|urls| urls.get(""))
|
||||
.and_then(Value::as_str)
|
||||
.expect("unnamed service URL in create response");
|
||||
|
||||
wait_for_authorization(service_url, "")
|
||||
.await
|
||||
.expect("default mode should strip Authorization");
|
||||
|
||||
let expose = run_cli(&[
|
||||
"service",
|
||||
"expose",
|
||||
&sandbox_name,
|
||||
SERVICE_PORT,
|
||||
"--authorization-mode",
|
||||
"bearer-passthrough",
|
||||
])
|
||||
.await
|
||||
.expect("run service re-expose");
|
||||
assert!(
|
||||
expose.status.success(),
|
||||
"service re-expose failed with exit {:?}: {}",
|
||||
expose.status.code(),
|
||||
String::from_utf8_lossy(&expose.stderr)
|
||||
);
|
||||
|
||||
wait_for_authorization(service_url, BEARER_TOKEN)
|
||||
.await
|
||||
.expect("passthrough mode should preserve Authorization");
|
||||
|
||||
sandbox.cleanup().await;
|
||||
}
|
||||
@@ -1774,6 +1774,8 @@ message ExposeServiceRequest {
|
||||
// Optional nonzero UUID for durable at-most-once admission. Successful results
|
||||
// can be replayed for 24 hours; see the API errors and retries reference.
|
||||
string request_id = 6;
|
||||
// Application authorization behavior. Omission resolves to STRIP.
|
||||
ServiceAuthorizationMode authorization_mode = 7;
|
||||
}
|
||||
|
||||
// Request to fetch an exposed sandbox service endpoint.
|
||||
@@ -1840,6 +1842,8 @@ message ServiceEndpoint {
|
||||
uint32 target_port = 5;
|
||||
// Whether browser-facing service routing is enabled for this endpoint.
|
||||
bool domain = 6;
|
||||
// Effective application authorization behavior for ingress requests.
|
||||
ServiceAuthorizationMode authorization_mode = 7;
|
||||
}
|
||||
|
||||
// Response containing a service endpoint and, when available, its local URL.
|
||||
@@ -3785,4 +3789,17 @@ message SandboxServiceExposure {
|
||||
string service = 1;
|
||||
// Loopback TCP port inside the sandbox.
|
||||
uint32 target_port = 2;
|
||||
// Application authorization behavior. Omission resolves to STRIP.
|
||||
ServiceAuthorizationMode authorization_mode = 3;
|
||||
}
|
||||
|
||||
// Controls whether an exposed service receives the request's application
|
||||
// Authorization header.
|
||||
enum ServiceAuthorizationMode {
|
||||
// Omission preserves the secure legacy behavior and resolves to STRIP.
|
||||
SERVICE_AUTHORIZATION_MODE_UNSPECIFIED = 0;
|
||||
// Remove Authorization before forwarding to the sandbox service.
|
||||
SERVICE_AUTHORIZATION_MODE_STRIP = 1;
|
||||
// Forward one syntactically valid bearer Authorization header unchanged.
|
||||
SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH = 2;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ from .sandbox import (
|
||||
SandboxStatusRef,
|
||||
SandboxTemplateClient,
|
||||
SandboxWorkloadTemplateProvenanceRef,
|
||||
ServiceAuthorizationMode,
|
||||
ServiceExposure,
|
||||
TlsConfig,
|
||||
WorkspaceClient,
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"SandboxStatusRef",
|
||||
"SandboxTemplateClient",
|
||||
"SandboxWorkloadTemplateProvenanceRef",
|
||||
"ServiceAuthorizationMode",
|
||||
"ServiceExposure",
|
||||
"TlsConfig",
|
||||
"WorkspaceClient",
|
||||
|
||||
@@ -17,6 +17,7 @@ import threading
|
||||
import time
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Never, SupportsIndex, TypeVar, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -115,6 +116,12 @@ def _service_exposure_messages(
|
||||
openshell_pb2.SandboxServiceExposure(
|
||||
service=exposure.service,
|
||||
target_port=exposure.target_port,
|
||||
authorization_mode=(
|
||||
openshell_pb2.SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH
|
||||
if exposure.authorization_mode
|
||||
== ServiceAuthorizationMode.BEARER_PASSTHROUGH
|
||||
else openshell_pb2.SERVICE_AUTHORIZATION_MODE_STRIP
|
||||
),
|
||||
)
|
||||
for exposure in exposures or ()
|
||||
]
|
||||
@@ -453,12 +460,20 @@ class SandboxStatusRef:
|
||||
main_process_started_at_ms: int | None = None
|
||||
|
||||
|
||||
class ServiceAuthorizationMode(IntEnum):
|
||||
"""Handling for an incoming application Authorization header."""
|
||||
|
||||
STRIP = 1
|
||||
BEARER_PASSTHROUGH = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceExposure:
|
||||
"""A loopback HTTP service to expose during sandbox creation."""
|
||||
|
||||
target_port: int
|
||||
service: str = ""
|
||||
authorization_mode: ServiceAuthorizationMode = ServiceAuthorizationMode.STRIP
|
||||
|
||||
|
||||
class _ImmutableLabels(dict[str, str]):
|
||||
|
||||
@@ -32,6 +32,7 @@ from openshell.sandbox import (
|
||||
SandboxRef,
|
||||
SandboxStatusRef,
|
||||
SandboxTemplateClient,
|
||||
ServiceAuthorizationMode,
|
||||
ServiceExposure,
|
||||
TlsConfig,
|
||||
_atomic_replace,
|
||||
@@ -2219,15 +2220,26 @@ def test_create_forwards_service_exposures() -> None:
|
||||
name="app-server",
|
||||
service_exposures=[
|
||||
ServiceExposure(target_port=4500),
|
||||
ServiceExposure(service="metrics", target_port=9090),
|
||||
ServiceExposure(
|
||||
service="metrics",
|
||||
target_port=9090,
|
||||
authorization_mode=ServiceAuthorizationMode.BEARER_PASSTHROUGH,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert stub.create_request is not None
|
||||
assert [
|
||||
(exposure.service, exposure.target_port)
|
||||
(exposure.service, exposure.target_port, exposure.authorization_mode)
|
||||
for exposure in stub.create_request.service_exposures
|
||||
] == [("", 4500), ("metrics", 9090)]
|
||||
] == [
|
||||
("", 4500, openshell_pb2.SERVICE_AUTHORIZATION_MODE_STRIP),
|
||||
(
|
||||
"metrics",
|
||||
9090,
|
||||
openshell_pb2.SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH,
|
||||
),
|
||||
]
|
||||
assert dict(ref.service_urls) == {
|
||||
"": "https://.example.test/",
|
||||
"metrics": "https://metrics.example.test/",
|
||||
|
||||
@@ -22,7 +22,7 @@ func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient {
|
||||
}
|
||||
|
||||
// Expose returns Unimplemented.
|
||||
func (c *fakeServiceClient) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) {
|
||||
func (c *fakeServiceClient) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool, _ ...v1.ExposeServiceOptions) (*types.ServiceEndpoint, error) {
|
||||
if c.closedFunc() {
|
||||
return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func ServiceEndpointFromProto(resp *pb.ServiceEndpointResponse) *types.ServiceEn
|
||||
result.Name = ep.GetName()
|
||||
result.TargetPort = ep.GetTargetPort()
|
||||
result.Domain = ep.GetDomain()
|
||||
result.AuthorizationMode = serviceAuthorizationModeFromProto(ep.GetAuthorizationMode())
|
||||
|
||||
if m := ep.GetMetadata(); m != nil {
|
||||
result.ID = m.GetId()
|
||||
@@ -48,12 +49,27 @@ func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointRespon
|
||||
Id: se.ID,
|
||||
Workspace: se.Workspace,
|
||||
},
|
||||
SandboxId: se.SandboxID,
|
||||
Sandbox: se.Sandbox,
|
||||
Name: se.Name,
|
||||
TargetPort: se.TargetPort,
|
||||
Domain: se.Domain,
|
||||
SandboxId: se.SandboxID,
|
||||
Sandbox: se.Sandbox,
|
||||
Name: se.Name,
|
||||
TargetPort: se.TargetPort,
|
||||
Domain: se.Domain,
|
||||
AuthorizationMode: serviceAuthorizationModeToProto(se.AuthorizationMode),
|
||||
},
|
||||
Url: se.URL,
|
||||
}
|
||||
}
|
||||
|
||||
func serviceAuthorizationModeToProto(mode types.ServiceAuthorizationMode) pb.ServiceAuthorizationMode {
|
||||
if mode == types.ServiceAuthorizationModeBearerPassthrough {
|
||||
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH
|
||||
}
|
||||
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP
|
||||
}
|
||||
|
||||
func serviceAuthorizationModeFromProto(mode pb.ServiceAuthorizationMode) types.ServiceAuthorizationMode {
|
||||
if mode == pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH {
|
||||
return types.ServiceAuthorizationModeBearerPassthrough
|
||||
}
|
||||
return types.ServiceAuthorizationModeStrip
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@ func TestServiceEndpointFromProto(t *testing.T) {
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: "svc-1",
|
||||
},
|
||||
SandboxId: "sb-1",
|
||||
Sandbox: "my-sandbox",
|
||||
Name: "http-server",
|
||||
TargetPort: 8080,
|
||||
Domain: true,
|
||||
SandboxId: "sb-1",
|
||||
Sandbox: "my-sandbox",
|
||||
Name: "http-server",
|
||||
TargetPort: 8080,
|
||||
Domain: true,
|
||||
AuthorizationMode: pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH,
|
||||
},
|
||||
Url: "https://svc-1.example.com",
|
||||
}
|
||||
@@ -37,6 +38,7 @@ func TestServiceEndpointFromProto(t *testing.T) {
|
||||
assert.Equal(t, "http-server", se.Name)
|
||||
assert.Equal(t, uint32(8080), se.TargetPort)
|
||||
assert.True(t, se.Domain)
|
||||
assert.Equal(t, v1.ServiceAuthorizationModeBearerPassthrough, se.AuthorizationMode)
|
||||
assert.Equal(t, "https://svc-1.example.com", se.URL)
|
||||
}
|
||||
|
||||
@@ -78,13 +80,14 @@ func TestServiceEndpointFromProto_Nil(t *testing.T) {
|
||||
|
||||
func TestServiceEndpointToProto(t *testing.T) {
|
||||
se := &v1.ServiceEndpoint{
|
||||
ID: "svc-1",
|
||||
SandboxID: "sb-1",
|
||||
Sandbox: "my-sandbox",
|
||||
Name: "http-server",
|
||||
TargetPort: 8080,
|
||||
Domain: true,
|
||||
URL: "https://svc-1.example.com",
|
||||
ID: "svc-1",
|
||||
SandboxID: "sb-1",
|
||||
Sandbox: "my-sandbox",
|
||||
Name: "http-server",
|
||||
TargetPort: 8080,
|
||||
Domain: true,
|
||||
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||
URL: "https://svc-1.example.com",
|
||||
}
|
||||
|
||||
resp := ServiceEndpointToProto(se)
|
||||
@@ -98,6 +101,7 @@ func TestServiceEndpointToProto(t *testing.T) {
|
||||
assert.Equal(t, "http-server", resp.Endpoint.Name)
|
||||
assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort)
|
||||
assert.True(t, resp.Endpoint.Domain)
|
||||
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH, resp.Endpoint.AuthorizationMode)
|
||||
assert.Equal(t, "https://svc-1.example.com", resp.Url)
|
||||
}
|
||||
|
||||
@@ -108,13 +112,14 @@ func TestServiceEndpointToProto_Nil(t *testing.T) {
|
||||
|
||||
func TestServiceEndpointRoundTrip(t *testing.T) {
|
||||
original := &v1.ServiceEndpoint{
|
||||
ID: "svc-rt",
|
||||
SandboxID: "sb-rt",
|
||||
Sandbox: "round-trip",
|
||||
Name: "web",
|
||||
TargetPort: 9090,
|
||||
Domain: false,
|
||||
URL: "http://localhost:9090",
|
||||
ID: "svc-rt",
|
||||
SandboxID: "sb-rt",
|
||||
Sandbox: "round-trip",
|
||||
Name: "web",
|
||||
TargetPort: 9090,
|
||||
Domain: false,
|
||||
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||
URL: "http://localhost:9090",
|
||||
}
|
||||
|
||||
proto := ServiceEndpointToProto(original)
|
||||
@@ -127,5 +132,6 @@ func TestServiceEndpointRoundTrip(t *testing.T) {
|
||||
assert.Equal(t, original.Name, back.Name)
|
||||
assert.Equal(t, original.TargetPort, back.TargetPort)
|
||||
assert.Equal(t, original.Domain, back.Domain)
|
||||
assert.Equal(t, original.AuthorizationMode, back.AuthorizationMode)
|
||||
assert.Equal(t, original.URL, back.URL)
|
||||
}
|
||||
|
||||
@@ -91,13 +91,21 @@ func serviceExposuresToProto(exposures []types.ServiceExposure) []*pb.SandboxSer
|
||||
result := make([]*pb.SandboxServiceExposure, 0, len(exposures))
|
||||
for _, exposure := range exposures {
|
||||
result = append(result, &pb.SandboxServiceExposure{
|
||||
Service: exposure.Service,
|
||||
TargetPort: exposure.TargetPort,
|
||||
Service: exposure.Service,
|
||||
TargetPort: exposure.TargetPort,
|
||||
AuthorizationMode: serviceAuthorizationModeToProto(exposure.AuthorizationMode),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func serviceAuthorizationModeToProto(mode types.ServiceAuthorizationMode) pb.ServiceAuthorizationMode {
|
||||
if mode == 0 {
|
||||
return pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP
|
||||
}
|
||||
return pb.ServiceAuthorizationMode(mode)
|
||||
}
|
||||
|
||||
func validateTemplateCreateSpec(spec *SandboxSpec) error {
|
||||
if spec == nil {
|
||||
return nil
|
||||
|
||||
@@ -318,7 +318,11 @@ func TestSandboxCreate(t *testing.T) {
|
||||
labels,
|
||||
CreateOptions{ServiceExposures: []ServiceExposure{
|
||||
{TargetPort: 4500},
|
||||
{Service: "metrics", TargetPort: 9090},
|
||||
{
|
||||
Service: "metrics",
|
||||
TargetPort: 9090,
|
||||
AuthorizationMode: ServiceAuthorizationModeBearerPassthrough,
|
||||
},
|
||||
}},
|
||||
)
|
||||
|
||||
@@ -334,7 +338,9 @@ func TestSandboxCreate(t *testing.T) {
|
||||
}, result.ServiceURLs)
|
||||
require.Len(t, mock.createRequest.GetServiceExposures(), 2)
|
||||
assert.Equal(t, uint32(4500), mock.createRequest.GetServiceExposures()[0].GetTargetPort())
|
||||
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_STRIP, mock.createRequest.GetServiceExposures()[0].GetAuthorizationMode())
|
||||
assert.Equal(t, "metrics", mock.createRequest.GetServiceExposures()[1].GetService())
|
||||
assert.Equal(t, pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH, mock.createRequest.GetServiceExposures()[1].GetAuthorizationMode())
|
||||
}
|
||||
|
||||
func TestSandboxCreate_DefaultGPURequest(t *testing.T) {
|
||||
|
||||
@@ -15,9 +15,24 @@ type ServiceEndpoint = types.ServiceEndpoint
|
||||
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
|
||||
type ServiceExposure = types.ServiceExposure
|
||||
|
||||
// ServiceAuthorizationMode controls handling of an incoming application Authorization header.
|
||||
type ServiceAuthorizationMode = types.ServiceAuthorizationMode
|
||||
|
||||
const (
|
||||
// ServiceAuthorizationModeStrip removes Authorization before proxying to the service.
|
||||
ServiceAuthorizationModeStrip = types.ServiceAuthorizationModeStrip
|
||||
// ServiceAuthorizationModeBearerPassthrough forwards one valid bearer credential unchanged.
|
||||
ServiceAuthorizationModeBearerPassthrough = types.ServiceAuthorizationModeBearerPassthrough
|
||||
)
|
||||
|
||||
// ExposeServiceOptions configures service exposure behavior.
|
||||
type ExposeServiceOptions struct {
|
||||
AuthorizationMode ServiceAuthorizationMode
|
||||
}
|
||||
|
||||
// ServiceInterface defines operations for managing sandbox service endpoints.
|
||||
type ServiceInterface interface {
|
||||
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error)
|
||||
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool, opts ...ExposeServiceOptions) (*ServiceEndpoint, error)
|
||||
Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error)
|
||||
List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error)
|
||||
ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error)
|
||||
|
||||
@@ -19,13 +19,18 @@ func newServiceClient(conn grpc.ClientConnInterface) *serviceClient {
|
||||
return &serviceClient{client: pb.NewOpenShellClient(conn)}
|
||||
}
|
||||
|
||||
func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) {
|
||||
func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool, opts ...ExposeServiceOptions) (*ServiceEndpoint, error) {
|
||||
authorizationMode := ServiceAuthorizationModeStrip
|
||||
if len(opts) > 0 && opts[0].AuthorizationMode != 0 {
|
||||
authorizationMode = opts[0].AuthorizationMode
|
||||
}
|
||||
resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{
|
||||
Sandbox: sandboxName,
|
||||
WorkspaceScope: namedWorkspaceScope(workspace),
|
||||
Name: serviceName,
|
||||
TargetPort: targetPort,
|
||||
Domain: domain,
|
||||
Sandbox: sandboxName,
|
||||
WorkspaceScope: namedWorkspaceScope(workspace),
|
||||
Name: serviceName,
|
||||
TargetPort: targetPort,
|
||||
Domain: domain,
|
||||
AuthorizationMode: pb.ServiceAuthorizationMode(authorizationMode),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
|
||||
@@ -55,10 +55,11 @@ func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServi
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: "ep-" + req.GetName(),
|
||||
},
|
||||
Sandbox: req.GetSandbox(),
|
||||
Name: req.GetName(),
|
||||
TargetPort: req.GetTargetPort(),
|
||||
Domain: req.GetDomain(),
|
||||
Sandbox: req.GetSandbox(),
|
||||
Name: req.GetName(),
|
||||
TargetPort: req.GetTargetPort(),
|
||||
Domain: req.GetDomain(),
|
||||
AuthorizationMode: req.GetAuthorizationMode(),
|
||||
},
|
||||
}
|
||||
if req.GetDomain() {
|
||||
@@ -150,7 +151,15 @@ func TestServiceExpose(t *testing.T) {
|
||||
client, cleanup := setupServiceTest(t, mock)
|
||||
defer cleanup()
|
||||
|
||||
ep, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true)
|
||||
ep, err := client.Expose(
|
||||
context.Background(),
|
||||
"default",
|
||||
"web-app",
|
||||
"api",
|
||||
8080,
|
||||
true,
|
||||
ExposeServiceOptions{AuthorizationMode: ServiceAuthorizationModeBearerPassthrough},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ep)
|
||||
@@ -159,6 +168,7 @@ func TestServiceExpose(t *testing.T) {
|
||||
assert.Equal(t, "api", ep.Name)
|
||||
assert.Equal(t, uint32(8080), ep.TargetPort)
|
||||
assert.True(t, ep.Domain)
|
||||
assert.Equal(t, ServiceAuthorizationModeBearerPassthrough, ep.AuthorizationMode)
|
||||
assert.Equal(t, "https://api.example.com", ep.URL)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,32 @@
|
||||
|
||||
package types
|
||||
|
||||
// ServiceAuthorizationMode controls handling of an incoming application Authorization header.
|
||||
type ServiceAuthorizationMode int32
|
||||
|
||||
const (
|
||||
// ServiceAuthorizationModeStrip removes Authorization before proxying to the sandbox service.
|
||||
ServiceAuthorizationModeStrip ServiceAuthorizationMode = 1
|
||||
// ServiceAuthorizationModeBearerPassthrough forwards one valid bearer credential unchanged.
|
||||
ServiceAuthorizationModeBearerPassthrough ServiceAuthorizationMode = 2
|
||||
)
|
||||
|
||||
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
|
||||
type ServiceExposure struct {
|
||||
Service string
|
||||
TargetPort uint32
|
||||
Service string
|
||||
TargetPort uint32
|
||||
AuthorizationMode ServiceAuthorizationMode
|
||||
}
|
||||
|
||||
// ServiceEndpoint represents an exposed HTTP service on a sandbox.
|
||||
type ServiceEndpoint struct {
|
||||
ID string
|
||||
SandboxID string
|
||||
Sandbox string
|
||||
Name string
|
||||
TargetPort uint32
|
||||
Domain bool
|
||||
URL string
|
||||
Workspace string
|
||||
ID string
|
||||
SandboxID string
|
||||
Sandbox string
|
||||
Name string
|
||||
TargetPort uint32
|
||||
Domain bool
|
||||
URL string
|
||||
Workspace string
|
||||
AuthorizationMode ServiceAuthorizationMode
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,16 @@ import {
|
||||
SandboxClient,
|
||||
SandboxTemplateClient,
|
||||
SCOPE_NAMES,
|
||||
ServiceAuthorizationMode,
|
||||
STATUS_NAMES,
|
||||
} from './client.js';
|
||||
import { OpenShell, SandboxPhase, SandboxRestartPolicy, ServiceStatus } from './gen/openshell_pb.js';
|
||||
import {
|
||||
OpenShell,
|
||||
ServiceAuthorizationMode as ProtoServiceAuthorizationMode,
|
||||
SandboxPhase,
|
||||
SandboxRestartPolicy,
|
||||
ServiceStatus,
|
||||
} from './gen/openshell_pb.js';
|
||||
import { PolicySource, SettingScope } from './gen/sandbox_pb.js';
|
||||
import type { ExecInteractiveSession, ExecInteractiveSessionControl } from './index.js';
|
||||
|
||||
@@ -276,7 +283,9 @@ describe('exec / execStream', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('sends create-time service exposures', async () => {
|
||||
let created: { serviceExposures?: Array<{ service?: string; targetPort?: number }> } = {};
|
||||
let created: {
|
||||
serviceExposures?: Array<{ service?: string; targetPort?: number; authorizationMode?: number }>;
|
||||
} = {};
|
||||
const sandbox = client({
|
||||
createSandbox: (req) => {
|
||||
created = req;
|
||||
@@ -292,12 +301,29 @@ describe('create', () => {
|
||||
|
||||
const result = await sandbox.create({
|
||||
image: 'img',
|
||||
serviceExposures: [{ targetPort: 4500 }, { service: 'metrics', targetPort: 9090 }],
|
||||
serviceExposures: [
|
||||
{ targetPort: 4500 },
|
||||
{
|
||||
service: 'metrics',
|
||||
targetPort: 9090,
|
||||
authorizationMode: ServiceAuthorizationMode.BearerPassthrough,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(created.serviceExposures?.map(({ service, targetPort }) => ({ service, targetPort }))).toEqual([
|
||||
{ service: '', targetPort: 4500 },
|
||||
{ service: 'metrics', targetPort: 9090 },
|
||||
expect(
|
||||
created.serviceExposures?.map(({ service, targetPort, authorizationMode }) => ({
|
||||
service,
|
||||
targetPort,
|
||||
authorizationMode,
|
||||
})),
|
||||
).toEqual([
|
||||
{ service: '', targetPort: 4500, authorizationMode: ProtoServiceAuthorizationMode.STRIP },
|
||||
{
|
||||
service: 'metrics',
|
||||
targetPort: 9090,
|
||||
authorizationMode: ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH,
|
||||
},
|
||||
]);
|
||||
expect(result.serviceUrls).toEqual({
|
||||
'': 'https://sb.example.test/',
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './g
|
||||
import {
|
||||
type ExecSandboxInputSchema,
|
||||
OpenShell,
|
||||
ServiceAuthorizationMode as ProtoServiceAuthorizationMode,
|
||||
SandboxPhase,
|
||||
SandboxRestartPolicy,
|
||||
type SandboxSpecSchema,
|
||||
@@ -168,6 +169,23 @@ export interface ServiceExposure {
|
||||
service?: string;
|
||||
/** Loopback TCP port inside the sandbox. */
|
||||
targetPort: number;
|
||||
/** Handling for an incoming application Authorization header. */
|
||||
authorizationMode?: ServiceAuthorizationMode;
|
||||
}
|
||||
|
||||
export enum ServiceAuthorizationMode {
|
||||
Strip = 'strip',
|
||||
BearerPassthrough = 'bearer_passthrough',
|
||||
}
|
||||
|
||||
function serviceAuthorizationModeToProto(mode: ServiceAuthorizationMode | undefined): ProtoServiceAuthorizationMode {
|
||||
switch (mode) {
|
||||
case ServiceAuthorizationMode.BearerPassthrough:
|
||||
return ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH;
|
||||
case ServiceAuthorizationMode.Strip:
|
||||
case undefined:
|
||||
return ProtoServiceAuthorizationMode.STRIP;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SandboxFromTemplateSpec {
|
||||
@@ -1023,6 +1041,7 @@ export class SandboxClient {
|
||||
spec.serviceExposures?.map((exposure) => ({
|
||||
service: exposure.service ?? '',
|
||||
targetPort: exposure.targetPort,
|
||||
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
|
||||
})) ?? [],
|
||||
});
|
||||
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
||||
@@ -1049,6 +1068,7 @@ export class SandboxClient {
|
||||
spec.serviceExposures?.map((exposure) => ({
|
||||
service: exposure.service ?? '',
|
||||
targetPort: exposure.targetPort,
|
||||
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
|
||||
})) ?? [],
|
||||
});
|
||||
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
||||
|
||||
@@ -53,7 +53,14 @@ export type {
|
||||
WaitOptions,
|
||||
WorkspaceListScope,
|
||||
} from './client.js';
|
||||
export { errorCode, OpenShellClient, Pager, SandboxClient, SandboxTemplateClient } from './client.js';
|
||||
export {
|
||||
errorCode,
|
||||
OpenShellClient,
|
||||
Pager,
|
||||
SandboxClient,
|
||||
SandboxTemplateClient,
|
||||
ServiceAuthorizationMode,
|
||||
} from './client.js';
|
||||
export type { ErrorInfo, FieldViolation, SdkErrorCode } from './errors.js';
|
||||
export { fromConnect, SdkError } from './errors.js';
|
||||
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
|
||||
|
||||
@@ -886,11 +886,13 @@ openshell sandbox create \
|
||||
--name my-app \
|
||||
--from my-app:latest \
|
||||
--expose 8080 \
|
||||
--expose-authorization-mode bearer-passthrough \
|
||||
--detach \
|
||||
-- ./start-server.sh
|
||||
|
||||
# Expose and manage an HTTP service through the gateway.
|
||||
openshell service expose my-app 8080 web
|
||||
openshell service expose my-app 8080 web \
|
||||
--authorization-mode bearer-passthrough
|
||||
openshell service list my-app
|
||||
openshell service list my-app --output json
|
||||
openshell service get my-app web
|
||||
@@ -905,6 +907,19 @@ request and keeps the sandbox running. Add `--output json` for automation; the
|
||||
result contains a `service_urls` map whose empty key is the unnamed endpoint.
|
||||
Use `openshell service expose` after creation to add or update named endpoints.
|
||||
|
||||
Exposed services strip `Authorization` by default. Select
|
||||
`bearer-passthrough` only when the application inside the sandbox authenticates
|
||||
its own clients. This mode accepts either no `Authorization` header or exactly
|
||||
one non-empty Bearer credential and forwards that value unchanged. It rejects
|
||||
duplicate, malformed, or non-Bearer authorization before contacting the
|
||||
application. The application remains responsible for validating the token, and
|
||||
the raw token reaches the sandbox process, so never log it. Service routes
|
||||
bypass control-plane RPC authorization, but they still use the gateway's
|
||||
existing listener, domain routing, and TLS configuration, including any client
|
||||
certificate requirement. See the published
|
||||
[sandbox service documentation](https://docs.nvidia.com/openshell/latest/how-it-works/sandboxes/overview.md)
|
||||
for the complete security contract.
|
||||
|
||||
Prefer loopback binds unless the user explicitly needs LAN-visible local access.
|
||||
|
||||
---
|
||||
|
||||
Generated
+164
-7
@@ -38,7 +38,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -804,7 +804,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -903,12 +903,15 @@ dependencies = [
|
||||
"noyalib",
|
||||
"prost",
|
||||
"rand",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
@@ -1122,6 +1125,20 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -1138,7 +1155,52 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1303,7 +1365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1312,6 +1374,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -1361,7 +1429,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1438,7 +1506,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1452,6 +1520,16 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -1603,6 +1681,12 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -1724,6 +1808,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -1733,6 +1826,70 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
Reference in New Issue
Block a user