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
|
They live in the `openshell-e2e` crate and use a shared harness for sandbox
|
||||||
lifecycle management, output parsing, and cleanup.
|
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:
|
Suites:
|
||||||
|
|
||||||
- Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests.
|
- 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::completers;
|
||||||
use openshell_cli::run;
|
use openshell_cli::run;
|
||||||
use openshell_cli::tls::TlsOptions;
|
use openshell_cli::tls::TlsOptions;
|
||||||
use openshell_core::proto::GpuResourceRequirements;
|
use openshell_core::proto::{GpuResourceRequirements, ServiceAuthorizationMode};
|
||||||
|
|
||||||
/// Resolved gateway context: name + gateway endpoint.
|
/// Resolved gateway context: name + gateway endpoint.
|
||||||
struct GatewayContext {
|
struct GatewayContext {
|
||||||
@@ -770,6 +770,22 @@ enum OutputFormat {
|
|||||||
Json,
|
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)]
|
#[derive(Clone, Debug, ValueEnum)]
|
||||||
enum CliProviderRefreshStrategy {
|
enum CliProviderRefreshStrategy {
|
||||||
Oauth2RefreshToken,
|
Oauth2RefreshToken,
|
||||||
@@ -1520,6 +1536,10 @@ enum SandboxCommands {
|
|||||||
)]
|
)]
|
||||||
expose: Option<u16>,
|
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.
|
/// Allocate a pseudo-terminal for the remote command.
|
||||||
/// Defaults to auto-detection (on when stdin and stdout are terminals).
|
/// Defaults to auto-detection (on when stdin and stdout are terminals).
|
||||||
/// Use --tty to force a PTY even when auto-detection fails, or
|
/// Use --tty to force a PTY even when auto-detection fails, or
|
||||||
@@ -2369,6 +2389,10 @@ enum ServiceCommands {
|
|||||||
|
|
||||||
/// Service name.
|
/// Service name.
|
||||||
service: Option<String>,
|
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.
|
/// List exposed sandbox service endpoints.
|
||||||
@@ -2914,6 +2938,7 @@ async fn run_async() -> Result<()> {
|
|||||||
sandbox,
|
sandbox,
|
||||||
service,
|
service,
|
||||||
target_port,
|
target_port,
|
||||||
|
authorization_mode,
|
||||||
} => {
|
} => {
|
||||||
let service = service.unwrap_or_default();
|
let service = service.unwrap_or_default();
|
||||||
run::service_expose(
|
run::service_expose(
|
||||||
@@ -2921,6 +2946,7 @@ async fn run_async() -> Result<()> {
|
|||||||
&sandbox,
|
&sandbox,
|
||||||
&service,
|
&service,
|
||||||
target_port,
|
target_port,
|
||||||
|
authorization_mode.into(),
|
||||||
&cli.workspace,
|
&cli.workspace,
|
||||||
&tls,
|
&tls,
|
||||||
)
|
)
|
||||||
@@ -3324,6 +3350,7 @@ async fn run_async() -> Result<()> {
|
|||||||
policy,
|
policy,
|
||||||
forward,
|
forward,
|
||||||
expose,
|
expose,
|
||||||
|
expose_authorization_mode,
|
||||||
tty,
|
tty,
|
||||||
no_tty,
|
no_tty,
|
||||||
detach,
|
detach,
|
||||||
@@ -3421,6 +3448,7 @@ async fn run_async() -> Result<()> {
|
|||||||
policy: policy.as_deref(),
|
policy: policy.as_deref(),
|
||||||
forward,
|
forward,
|
||||||
expose,
|
expose,
|
||||||
|
expose_authorization_mode: expose_authorization_mode.into(),
|
||||||
command: &command,
|
command: &command,
|
||||||
tty_override,
|
tty_override,
|
||||||
auto_providers_override,
|
auto_providers_override,
|
||||||
@@ -6716,9 +6744,19 @@ mod tests {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Commands::Sandbox {
|
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, Some(4500));
|
||||||
|
assert_eq!(
|
||||||
|
expose_authorization_mode,
|
||||||
|
CliServiceAuthorizationMode::Strip
|
||||||
|
);
|
||||||
assert!(detach);
|
assert!(detach);
|
||||||
}
|
}
|
||||||
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
|
other => panic!("expected SandboxCommands::Create, got: {other:?}"),
|
||||||
@@ -6746,6 +6784,44 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
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]
|
#[test]
|
||||||
fn service_expose_accepts_positional_target_port_and_service() {
|
fn service_expose_accepts_positional_target_port_and_service() {
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
@@ -6765,11 +6841,13 @@ mod tests {
|
|||||||
sandbox,
|
sandbox,
|
||||||
target_port,
|
target_port,
|
||||||
service,
|
service,
|
||||||
|
authorization_mode,
|
||||||
}),
|
}),
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(sandbox, "my-sandbox");
|
assert_eq!(sandbox, "my-sandbox");
|
||||||
assert_eq!(target_port, 8080);
|
assert_eq!(target_port, 8080);
|
||||||
assert_eq!(service.as_deref(), Some("api"));
|
assert_eq!(service.as_deref(), Some("api"));
|
||||||
|
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
|
||||||
}
|
}
|
||||||
other => panic!("expected service expose command, got: {other:?}"),
|
other => panic!("expected service expose command, got: {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -6787,16 +6865,45 @@ mod tests {
|
|||||||
sandbox,
|
sandbox,
|
||||||
target_port,
|
target_port,
|
||||||
service,
|
service,
|
||||||
|
authorization_mode,
|
||||||
}),
|
}),
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(sandbox, "my-sandbox");
|
assert_eq!(sandbox, "my-sandbox");
|
||||||
assert_eq!(target_port, 8080);
|
assert_eq!(target_port, 8080);
|
||||||
assert_eq!(service, None);
|
assert_eq!(service, None);
|
||||||
|
assert_eq!(authorization_mode, CliServiceAuthorizationMode::Strip);
|
||||||
}
|
}
|
||||||
other => panic!("expected service expose command, got: {other:?}"),
|
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]
|
#[test]
|
||||||
fn service_alias_parses_service_commands() {
|
fn service_alias_parses_service_commands() {
|
||||||
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
|
let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"])
|
||||||
@@ -6809,6 +6916,7 @@ mod tests {
|
|||||||
sandbox,
|
sandbox,
|
||||||
target_port,
|
target_port,
|
||||||
service,
|
service,
|
||||||
|
..
|
||||||
}),
|
}),
|
||||||
}) => {
|
}) => {
|
||||||
assert_eq!(sandbox, "my-sandbox");
|
assert_eq!(sandbox, "my-sandbox");
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ use openshell_core::proto::{
|
|||||||
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
|
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
|
||||||
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
|
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
|
||||||
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||||
SandboxWorkloadTemplateSpec, ServiceEndpointResponse, SettingScope, StartSandboxRequest,
|
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpointResponse, SettingScope,
|
||||||
StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
|
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
|
||||||
WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
|
UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
|
||||||
};
|
};
|
||||||
use openshell_core::settings;
|
use openshell_core::settings;
|
||||||
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
|
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
|
||||||
@@ -443,6 +443,7 @@ pub struct SandboxCreateConfig<'a> {
|
|||||||
pub policy: Option<&'a str>,
|
pub policy: Option<&'a str>,
|
||||||
pub forward: Option<ForwardSpec>,
|
pub forward: Option<ForwardSpec>,
|
||||||
pub expose: Option<u16>,
|
pub expose: Option<u16>,
|
||||||
|
pub expose_authorization_mode: ServiceAuthorizationMode,
|
||||||
pub command: &'a [String],
|
pub command: &'a [String],
|
||||||
pub tty_override: Option<bool>,
|
pub tty_override: Option<bool>,
|
||||||
pub auto_providers_override: Option<bool>,
|
pub auto_providers_override: Option<bool>,
|
||||||
@@ -472,6 +473,7 @@ impl Default for SandboxCreateConfig<'_> {
|
|||||||
policy: None,
|
policy: None,
|
||||||
forward: None,
|
forward: None,
|
||||||
expose: None,
|
expose: None,
|
||||||
|
expose_authorization_mode: ServiceAuthorizationMode::Strip,
|
||||||
command: &[],
|
command: &[],
|
||||||
tty_override: None,
|
tty_override: None,
|
||||||
auto_providers_override: None,
|
auto_providers_override: None,
|
||||||
@@ -509,6 +511,7 @@ pub async fn sandbox_create(
|
|||||||
policy,
|
policy,
|
||||||
forward,
|
forward,
|
||||||
expose,
|
expose,
|
||||||
|
expose_authorization_mode,
|
||||||
command,
|
command,
|
||||||
tty_override,
|
tty_override,
|
||||||
auto_providers_override,
|
auto_providers_override,
|
||||||
@@ -693,6 +696,7 @@ pub async fn sandbox_create(
|
|||||||
.map(|target_port| SandboxServiceExposure {
|
.map(|target_port| SandboxServiceExposure {
|
||||||
service: String::new(),
|
service: String::new(),
|
||||||
target_port: u32::from(target_port),
|
target_port: u32::from(target_port),
|
||||||
|
authorization_mode: expose_authorization_mode as i32,
|
||||||
})
|
})
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -3823,11 +3827,20 @@ pub async fn service_expose(
|
|||||||
sandbox: &str,
|
sandbox: &str,
|
||||||
service: &str,
|
service: &str,
|
||||||
target_port: u16,
|
target_port: u16,
|
||||||
|
authorization_mode: ServiceAuthorizationMode,
|
||||||
workspace: &str,
|
workspace: &str,
|
||||||
tls: &TlsOptions,
|
tls: &TlsOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let response =
|
let response = expose_service_endpoint(
|
||||||
expose_service_endpoint(server, sandbox, service, target_port, workspace, tls).await?;
|
server,
|
||||||
|
sandbox,
|
||||||
|
service,
|
||||||
|
target_port,
|
||||||
|
authorization_mode,
|
||||||
|
workspace,
|
||||||
|
tls,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if service.is_empty() {
|
if service.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
@@ -3857,6 +3870,7 @@ async fn expose_service_endpoint(
|
|||||||
sandbox: &str,
|
sandbox: &str,
|
||||||
service: &str,
|
service: &str,
|
||||||
target_port: u16,
|
target_port: u16,
|
||||||
|
authorization_mode: ServiceAuthorizationMode,
|
||||||
workspace: &str,
|
workspace: &str,
|
||||||
tls: &TlsOptions,
|
tls: &TlsOptions,
|
||||||
) -> Result<ServiceEndpointResponse> {
|
) -> Result<ServiceEndpointResponse> {
|
||||||
@@ -3868,6 +3882,7 @@ async fn expose_service_endpoint(
|
|||||||
name: service.to_string(),
|
name: service.to_string(),
|
||||||
target_port: u32::from(target_port),
|
target_port: u32::from(target_port),
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: authorization_mode as i32,
|
||||||
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
workspace_scope: Some(openshell_core::proto::workspace_selector(
|
||||||
workspace.to_string(),
|
workspace.to_string(),
|
||||||
)),
|
)),
|
||||||
@@ -4040,6 +4055,7 @@ fn print_service_endpoint_table(
|
|||||||
.map_or("", |m| m.workspace.as_str());
|
.map_or("", |m| m.workspace.as_str());
|
||||||
let service = service_display_name(&endpoint.name).to_string();
|
let service = service_display_name(&endpoint.name).to_string();
|
||||||
let target = format!("127.0.0.1:{}", endpoint.target_port);
|
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() {
|
let url = if response.url.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
@@ -4050,6 +4066,7 @@ fn print_service_endpoint_table(
|
|||||||
endpoint.sandbox.clone(),
|
endpoint.sandbox.clone(),
|
||||||
service,
|
service,
|
||||||
target,
|
target,
|
||||||
|
authorization,
|
||||||
url,
|
url,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -4061,7 +4078,7 @@ fn print_service_endpoint_table(
|
|||||||
|
|
||||||
let ws_width = if all_workspaces {
|
let ws_width = if all_workspaces {
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.map(|(ws, _, _, _, _)| ws.len())
|
.map(|(ws, _, _, _, _, _)| ws.len())
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(9)
|
.unwrap_or(9)
|
||||||
.max(9)
|
.max(9)
|
||||||
@@ -4070,50 +4087,52 @@ fn print_service_endpoint_table(
|
|||||||
};
|
};
|
||||||
let sandbox_width = rows
|
let sandbox_width = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, sandbox, _, _, _)| sandbox.len())
|
.map(|(_, sandbox, _, _, _, _)| sandbox.len())
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(7)
|
.unwrap_or(7)
|
||||||
.max(7);
|
.max(7);
|
||||||
let service_width = rows
|
let service_width = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, _, service, _, _)| service.len())
|
.map(|(_, _, service, _, _, _)| service.len())
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(7)
|
.unwrap_or(7)
|
||||||
.max(7);
|
.max(7);
|
||||||
let target_width = rows
|
let target_width = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, _, _, target, _)| target.len())
|
.map(|(_, _, _, target, _, _)| target.len())
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(6)
|
.unwrap_or(6)
|
||||||
.max(6);
|
.max(6);
|
||||||
|
|
||||||
if all_workspaces {
|
if all_workspaces {
|
||||||
println!(
|
println!(
|
||||||
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
"{:<ws_width$} {:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
|
||||||
"WORKSPACE".bold(),
|
"WORKSPACE".bold(),
|
||||||
"SANDBOX".bold(),
|
"SANDBOX".bold(),
|
||||||
"SERVICE".bold(),
|
"SERVICE".bold(),
|
||||||
"TARGET".bold(),
|
"TARGET".bold(),
|
||||||
|
"AUTHORIZATION".bold(),
|
||||||
"URL".bold(),
|
"URL".bold(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {}",
|
"{:<sandbox_width$} {:<service_width$} {:<target_width$} {:<20} {}",
|
||||||
"SANDBOX".bold(),
|
"SANDBOX".bold(),
|
||||||
"SERVICE".bold(),
|
"SERVICE".bold(),
|
||||||
"TARGET".bold(),
|
"TARGET".bold(),
|
||||||
|
"AUTHORIZATION".bold(),
|
||||||
"URL".bold(),
|
"URL".bold(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (workspace, sandbox, service, target, url) in rows {
|
for (workspace, sandbox, service, target, authorization, url) in rows {
|
||||||
if all_workspaces {
|
if all_workspaces {
|
||||||
println!(
|
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 {
|
} else {
|
||||||
println!(
|
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,
|
"sandbox": endpoint.sandbox,
|
||||||
"service": endpoint.name,
|
"service": endpoint.name,
|
||||||
"target_port": endpoint.target_port,
|
"target_port": endpoint.target_port,
|
||||||
|
"authorization_mode": service_authorization_mode_name(endpoint.authorization_mode),
|
||||||
"url": url,
|
"url": url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -4147,6 +4167,13 @@ fn service_display_name(service: &str) -> &str {
|
|||||||
if service.is_empty() { "-" } else { service }
|
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.
|
/// Read gcloud Application Default Credentials from disk.
|
||||||
///
|
///
|
||||||
/// Returns `(client_id, client_secret, refresh_token)`.
|
/// Returns `(client_id, client_secret, refresh_token)`.
|
||||||
@@ -6521,8 +6548,9 @@ mod tests {
|
|||||||
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
|
PolicySource, PolicyStatus, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase,
|
||||||
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
|
SandboxPolicy, SandboxPolicyRevision, SandboxResources, SandboxRestartPolicy, SandboxSpec,
|
||||||
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
SandboxStatus, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceEndpoint,
|
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||||
ServiceEndpointResponse, WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta,
|
ServiceEndpoint, ServiceEndpointResponse, WorkspaceMember, WorkspaceRole,
|
||||||
|
datamodel::v1::ObjectMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -6639,6 +6667,7 @@ mod tests {
|
|||||||
sandbox: "api".to_string(),
|
sandbox: "api".to_string(),
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
url: "https://api.openshell.localhost:3000/".to_string(),
|
url: "https://api.openshell.localhost:3000/".to_string(),
|
||||||
@@ -6653,6 +6682,7 @@ mod tests {
|
|||||||
"sandbox": "api",
|
"sandbox": "api",
|
||||||
"service": "",
|
"service": "",
|
||||||
"target_port": 8080,
|
"target_port": 8080,
|
||||||
|
"authorization_mode": "bearer_passthrough",
|
||||||
"url": "https://api.openshell.localhost:17670/",
|
"url": "https://api.openshell.localhost:17670/",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2784,6 +2784,8 @@ async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() {
|
|||||||
name: Some("sandbox"),
|
name: Some("sandbox"),
|
||||||
keep: false,
|
keep: false,
|
||||||
expose: Some(4500),
|
expose: Some(4500),
|
||||||
|
expose_authorization_mode:
|
||||||
|
openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough,
|
||||||
detach: true,
|
detach: true,
|
||||||
..test_config()
|
..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.len(), 1);
|
||||||
assert_eq!(create_requests[0].service_exposures[0].service, "");
|
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].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());
|
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]
|
#[tokio::test]
|
||||||
async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() {
|
async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() {
|
||||||
let server = run_server().await;
|
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.
|
with a discriminable kind.
|
||||||
|
|
||||||
Set `SandboxSpec::service_exposures` to register named or unnamed loopback HTTP
|
Set `SandboxSpec::service_exposures` to register named or unnamed loopback HTTP
|
||||||
services during creation. Each `ServiceExposure` contains a service name and a
|
services during creation. Each `ServiceExposure` contains a service name, a
|
||||||
target port; an empty name selects the unnamed endpoint. The returned
|
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.
|
`SandboxRef::service_urls` map contains each routed URL under the same name.
|
||||||
|
|
||||||
Curated calls without a workspace argument explicitly select the `default`
|
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 {
|
.map(|exposure| proto::SandboxServiceExposure {
|
||||||
service: exposure.service,
|
service: exposure.service,
|
||||||
target_port: u32::from(exposure.target_port),
|
target_port: u32::from(exposure.target_port),
|
||||||
|
authorization_mode: proto::ServiceAuthorizationMode::from(
|
||||||
|
exposure.authorization_mode,
|
||||||
|
) as i32,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
@@ -1397,6 +1400,9 @@ fn create_sandbox_from_template_request(
|
|||||||
.map(|exposure| proto::SandboxServiceExposure {
|
.map(|exposure| proto::SandboxServiceExposure {
|
||||||
service: exposure.service,
|
service: exposure.service,
|
||||||
target_port: u32::from(exposure.target_port),
|
target_port: u32::from(exposure.target_port),
|
||||||
|
authorization_mode: proto::ServiceAuthorizationMode::from(
|
||||||
|
exposure.authorization_mode,
|
||||||
|
) as i32,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,6 @@ pub use types::{
|
|||||||
LogLine, PlatformEvent, SandboxPhase, SandboxRef, SandboxResources, SandboxRestartPolicy,
|
LogLine, PlatformEvent, SandboxPhase, SandboxRef, SandboxResources, SandboxRestartPolicy,
|
||||||
SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec,
|
SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplateCreateSpec,
|
||||||
SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
SandboxTemplateListOptions, SandboxWorkloadConfig, SandboxWorkloadTemplate,
|
||||||
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceExposure, ServiceStatus,
|
SandboxWorkloadTemplateProvenance, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||||
WatchEvent, WatchOptions, WorkspaceRef,
|
ServiceExposure, ServiceStatus, WatchEvent, WatchOptions, WorkspaceRef,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -296,6 +296,27 @@ pub struct ServiceExposure {
|
|||||||
pub service: String,
|
pub service: String,
|
||||||
/// Loopback TCP port inside the sandbox.
|
/// Loopback TCP port inside the sandbox.
|
||||||
pub target_port: u16,
|
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.
|
/// 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::{
|
use openshell_sdk::{
|
||||||
AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError,
|
AuthConfig, ClientConfig, ExecOptions, ListOptions, OpenShellClient, Refresh, RefreshError,
|
||||||
RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec,
|
RefreshedToken, SandboxPhase, SandboxSpec, SandboxTemplateCreateSpec,
|
||||||
SandboxTemplateListOptions, ServiceExposure, ServiceStatus as SdkServiceStatus, WatchEvent,
|
SandboxTemplateListOptions, ServiceAuthorizationMode, ServiceExposure,
|
||||||
WatchOptions,
|
ServiceStatus as SdkServiceStatus, WatchEvent, WatchOptions,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1139,6 +1139,7 @@ async fn create_sandbox_passes_spec_through() {
|
|||||||
service_exposures: vec![ServiceExposure {
|
service_exposures: vec![ServiceExposure {
|
||||||
service: "web".to_string(),
|
service: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::BearerPassthrough,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..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.len(), 1);
|
||||||
assert_eq!(observed.service_exposures[0].service, "web");
|
assert_eq!(observed.service_exposures[0].service, "web");
|
||||||
assert_eq!(observed.service_exposures[0].target_port, 8080);
|
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();
|
let observed_spec = observed.spec.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
observed_spec
|
observed_spec
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ use hyper_util::rt::TokioIo;
|
|||||||
use openshell_core::extension_protocol::{
|
use openshell_core::extension_protocol::{
|
||||||
ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate,
|
ExtensionFamily, NegotiatedExtension, gateway_metadata, negotiate,
|
||||||
};
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
use openshell_core::proto::ServiceAuthorizationMode;
|
||||||
use openshell_core::proto::compute::v1::{
|
use openshell_core::proto::compute::v1::{
|
||||||
AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest,
|
AuthenticateSandboxRequest, CreateSandboxRequest, DeleteSandboxRequest, DeleteWorkspaceRequest,
|
||||||
DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements,
|
DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverResourceRequirements,
|
||||||
@@ -9509,6 +9511,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
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::open_shell_server::OpenShell;
|
||||||
use openshell_core::proto::{
|
use openshell_core::proto::{
|
||||||
CreateSandboxRequest, SandboxServiceExposure, SandboxSpec, SandboxWorkloadConfig,
|
CreateSandboxRequest, SandboxServiceExposure, SandboxSpec, SandboxWorkloadConfig,
|
||||||
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, WorkspaceMember, WorkspaceRole,
|
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
|
||||||
|
WorkspaceMember, WorkspaceRole,
|
||||||
};
|
};
|
||||||
use openshell_core::rpc_error::StatusExt;
|
use openshell_core::rpc_error::StatusExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -113,6 +114,7 @@ async fn create_sandbox_replay_preserves_service_urls() {
|
|||||||
service_exposures: vec![SandboxServiceExposure {
|
service_exposures: vec![SandboxServiceExposure {
|
||||||
service: "web".into(),
|
service: "web".into(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}],
|
}],
|
||||||
request_id: uuid::Uuid::new_v4().to_string(),
|
request_id: uuid::Uuid::new_v4().to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -124,13 +126,26 @@ async fn create_sandbox_replay_preserves_service_urls() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_inner();
|
.into_inner();
|
||||||
let replay = service
|
let replay = service
|
||||||
.create_sandbox(authed_request(request))
|
.create_sandbox(authed_request(request.clone()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true");
|
assert_eq!(replay.metadata().get("openshell-replayed").unwrap(), "true");
|
||||||
assert_eq!(replay.get_ref().service_urls, original.service_urls);
|
assert_eq!(replay.get_ref().service_urls, original.service_urls);
|
||||||
assert_eq!(replay.into_inner(), original);
|
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) {
|
async fn exercise_backend(url: &str) {
|
||||||
|
|||||||
@@ -680,6 +680,11 @@ async fn handle_create_sandbox_inner(
|
|||||||
&sandbox,
|
&sandbox,
|
||||||
&exposure.service,
|
&exposure.service,
|
||||||
exposure.target_port,
|
exposure.target_port,
|
||||||
|
super::service::validate_service_exposure_request(
|
||||||
|
&exposure.service,
|
||||||
|
exposure.target_port,
|
||||||
|
exposure.authorization_mode,
|
||||||
|
)?,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -741,7 +746,11 @@ fn validate_create_sandbox_request_pre_io(
|
|||||||
}
|
}
|
||||||
let mut service_names = HashSet::with_capacity(request.service_exposures.len());
|
let mut service_names = HashSet::with_capacity(request.service_exposures.len());
|
||||||
for exposure in &request.service_exposures {
|
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()) {
|
if !service_names.insert(exposure.service.as_str()) {
|
||||||
return Err(Status::invalid_argument(format!(
|
return Err(Status::invalid_argument(format!(
|
||||||
"duplicate service exposure name: '{}'",
|
"duplicate service exposure name: '{}'",
|
||||||
@@ -3924,7 +3933,9 @@ mod tests {
|
|||||||
test_server_state_with_driver,
|
test_server_state_with_driver,
|
||||||
};
|
};
|
||||||
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||||
use openshell_core::proto::{GpuResourceRequirements, SandboxServiceExposure, ServiceEndpoint};
|
use openshell_core::proto::{
|
||||||
|
GpuResourceRequirements, SandboxServiceExposure, ServiceAuthorizationMode, ServiceEndpoint,
|
||||||
|
};
|
||||||
|
|
||||||
// ---- shell_escape ----
|
// ---- shell_escape ----
|
||||||
|
|
||||||
@@ -6720,10 +6731,12 @@ mod tests {
|
|||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: String::new(),
|
service: String::new(),
|
||||||
target_port: 4500,
|
target_port: 4500,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
|
||||||
},
|
},
|
||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: "metrics".to_string(),
|
service: "metrics".to_string(),
|
||||||
target_port: 9090,
|
target_port: 9090,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::BearerPassthrough as i32,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -6756,9 +6769,46 @@ mod tests {
|
|||||||
assert_eq!(endpoint.name, service);
|
assert_eq!(endpoint.name, service);
|
||||||
assert_eq!(endpoint.target_port, target_port);
|
assert_eq!(endpoint.target_port, target_port);
|
||||||
assert!(endpoint.domain);
|
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]
|
#[tokio::test]
|
||||||
async fn create_sandbox_begins_rollback_when_service_exposure_fails() {
|
async fn create_sandbox_begins_rollback_when_service_exposure_fails() {
|
||||||
let state = test_server_state().await;
|
let state = test_server_state().await;
|
||||||
@@ -6788,10 +6838,12 @@ mod tests {
|
|||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: "web".to_string(),
|
service: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
},
|
},
|
||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: "metrics".to_string(),
|
service: "metrics".to_string(),
|
||||||
target_port: 9090,
|
target_port: 9090,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -6875,10 +6927,12 @@ mod tests {
|
|||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: "web".to_string(),
|
service: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
},
|
},
|
||||||
SandboxServiceExposure {
|
SandboxServiceExposure {
|
||||||
service: "web".to_string(),
|
service: "web".to_string(),
|
||||||
target_port: 8081,
|
target_port: 8081,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ use std::sync::Arc;
|
|||||||
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
use openshell_core::proto::datamodel::v1::ObjectMeta;
|
||||||
use openshell_core::proto::{
|
use openshell_core::proto::{
|
||||||
DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest,
|
DeleteServiceRequest, DeleteServiceResponse, ExposeServiceRequest, GetServiceRequest,
|
||||||
ListServicesRequest, ListServicesResponse, Sandbox, ServiceEndpoint, ServiceEndpointResponse,
|
ListServicesRequest, ListServicesResponse, Sandbox, ServiceAuthorizationMode, ServiceEndpoint,
|
||||||
|
ServiceEndpointResponse,
|
||||||
};
|
};
|
||||||
use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace};
|
use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace};
|
||||||
use prost::Message as _;
|
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())
|
super::workspace::resolve_workspace(state.store.as_ref(), sandbox.object_workspace())
|
||||||
.await?
|
.await?
|
||||||
.ensure_active()?;
|
.ensure_active()?;
|
||||||
validate_service_exposure_request(&req.name, req.target_port)?;
|
let authorization_mode =
|
||||||
expose_service_endpoint(state, &workspace, &sandbox, &req.name, req.target_port).await
|
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(
|
pub(super) fn validate_service_exposure_request(
|
||||||
service: &str,
|
service: &str,
|
||||||
target_port: u32,
|
target_port: u32,
|
||||||
) -> Result<(), Status> {
|
authorization_mode: i32,
|
||||||
|
) -> Result<ServiceAuthorizationMode, Status> {
|
||||||
validate_optional_endpoint_name("service", service, MAX_SERVICE_NAME_LEN)?;
|
validate_optional_endpoint_name("service", service, MAX_SERVICE_NAME_LEN)?;
|
||||||
if target_port == 0 || target_port > u32::from(u16::MAX) {
|
if target_port == 0 || target_port > u32::from(u16::MAX) {
|
||||||
return Err(Status::invalid_argument("target_port must be in 1..=65535"));
|
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(
|
pub(super) async fn expose_service_endpoint(
|
||||||
@@ -70,6 +89,7 @@ pub(super) async fn expose_service_endpoint(
|
|||||||
sandbox: &Sandbox,
|
sandbox: &Sandbox,
|
||||||
service: &str,
|
service: &str,
|
||||||
target_port: u32,
|
target_port: u32,
|
||||||
|
authorization_mode: ServiceAuthorizationMode,
|
||||||
) -> Result<Response<ServiceEndpointResponse>, Status> {
|
) -> Result<Response<ServiceEndpointResponse>, Status> {
|
||||||
let sandbox_name = sandbox.object_name();
|
let sandbox_name = sandbox.object_name();
|
||||||
|
|
||||||
@@ -132,6 +152,7 @@ pub(super) async fn expose_service_endpoint(
|
|||||||
name: service.to_string(),
|
name: service.to_string(),
|
||||||
target_port,
|
target_port,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: authorization_mode as i32,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single-attempt CAS write: fails with ABORTED on concurrent modification
|
// Single-attempt CAS write: fails with ABORTED on concurrent modification
|
||||||
@@ -344,8 +365,16 @@ async fn get_service_endpoint(
|
|||||||
|
|
||||||
fn service_endpoint_response(
|
fn service_endpoint_response(
|
||||||
state: &Arc<ServerState>,
|
state: &Arc<ServerState>,
|
||||||
endpoint: ServiceEndpoint,
|
mut endpoint: ServiceEndpoint,
|
||||||
) -> ServiceEndpointResponse {
|
) -> 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 workspace = endpoint.object_workspace();
|
||||||
let url =
|
let url =
|
||||||
service_routing::endpoint_url(&state.config, workspace, &endpoint.sandbox, &endpoint.name)
|
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());
|
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]
|
#[tokio::test]
|
||||||
async fn endpoint_lifecycle_round_trip() {
|
async fn endpoint_lifecycle_round_trip() {
|
||||||
let state = test_server_state().await;
|
let state = test_server_state().await;
|
||||||
@@ -472,12 +595,17 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Unspecified as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_inner();
|
.into_inner();
|
||||||
assert_eq!(exposed.endpoint.as_ref().unwrap().target_port, 8080);
|
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(
|
let listed = handle_list_services(
|
||||||
&state,
|
&state,
|
||||||
@@ -511,6 +639,26 @@ mod tests {
|
|||||||
.into_inner();
|
.into_inner();
|
||||||
assert_eq!(fetched.endpoint.as_ref().unwrap().target_port, 8080);
|
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(
|
let deleted = handle_delete_service(
|
||||||
&state,
|
&state,
|
||||||
authed_request(DeleteServiceRequest {
|
authed_request(DeleteServiceRequest {
|
||||||
@@ -643,6 +791,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -661,6 +810,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 9090,
|
target_port: 9090,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -713,6 +863,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 7070,
|
target_port: 7070,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -732,6 +883,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -750,6 +902,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 9090,
|
target_port: 9090,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -840,6 +993,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -856,6 +1010,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 9090,
|
target_port: 9090,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -999,6 +1154,7 @@ mod tests {
|
|||||||
name: "api".to_string(),
|
name: "api".to_string(),
|
||||||
target_port: 3000,
|
target_port: 3000,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header
|
|||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use openshell_core::ObjectId;
|
use openshell_core::ObjectId;
|
||||||
use openshell_core::config::ServiceRoutingConfig;
|
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::{
|
use openshell_ocsf::{
|
||||||
ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, EventContext,
|
ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, EventContext,
|
||||||
HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder,
|
HttpActivityBuilder, HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder,
|
||||||
@@ -428,6 +430,19 @@ async fn proxy_to_endpoint(
|
|||||||
);
|
);
|
||||||
return Err(err);
|
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 websocket_upgrade = is_websocket_upgrade(&req);
|
||||||
let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut 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?,
|
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 replay = reused.then(|| replayable_request(&upstream)).flatten();
|
||||||
let first_attempt = if reused {
|
let first_attempt = if reused {
|
||||||
sender.try_send_request(upstream).await.map_err(|mut err| {
|
sender.try_send_request(upstream).await.map_err(|mut err| {
|
||||||
@@ -626,6 +641,7 @@ fn build_upstream_request(
|
|||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
target_port: u16,
|
target_port: u16,
|
||||||
preserve_upgrade_headers: bool,
|
preserve_upgrade_headers: bool,
|
||||||
|
authorization_mode: ServiceAuthorizationMode,
|
||||||
) -> Result<Request<Body>, ServiceRouteError> {
|
) -> Result<Request<Body>, ServiceRouteError> {
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let path = parts.uri.path_and_query().map_or("/", |path| path.as_str());
|
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 {
|
for (name, value) in &parts.headers {
|
||||||
if (is_hop_by_hop_header(name)
|
if (is_hop_by_hop_header(name)
|
||||||
&& !(preserve_upgrade_headers && is_websocket_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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -724,15 +740,83 @@ fn is_websocket_hop_by_hop_header(name: &header::HeaderName) -> bool {
|
|||||||
matches!(name.as_str(), "connection" | "upgrade")
|
matches!(name.as_str(), "connection" | "upgrade")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_gateway_auth_header(name: &header::HeaderName) -> bool {
|
pub fn effective_authorization_mode(value: i32) -> ServiceAuthorizationMode {
|
||||||
matches!(
|
match ServiceAuthorizationMode::try_from(value) {
|
||||||
name.as_str(),
|
Ok(ServiceAuthorizationMode::BearerPassthrough) => {
|
||||||
"authorization"
|
ServiceAuthorizationMode::BearerPassthrough
|
||||||
| "cf-access-jwt-assertion"
|
}
|
||||||
| "x-forwarded-client-cert"
|
Ok(ServiceAuthorizationMode::Unspecified | ServiceAuthorizationMode::Strip) | Err(_) => {
|
||||||
| "x-ssl-client-cert"
|
ServiceAuthorizationMode::Strip
|
||||||
| "x-client-cert"
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
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("endpoint_name", endpoint_name(endpoint))
|
||||||
.unmapped("service_name", endpoint.name.clone())
|
.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() {
|
if !url.is_empty() {
|
||||||
builder = builder.unmapped("url", url.to_string());
|
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("endpoint_name", endpoint_name(endpoint))
|
||||||
.unmapped("service_name", endpoint.name.clone())
|
.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),
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +1082,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
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"]["endpoint_name"], "my-sandbox--web");
|
||||||
assert_eq!(json["unmapped"]["service_name"], "web");
|
assert_eq!(json["unmapped"]["service_name"], "web");
|
||||||
assert_eq!(json["unmapped"]["target_port"], 8080);
|
assert_eq!(json["unmapped"]["target_port"], 8080);
|
||||||
|
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
|
||||||
assert!(
|
assert!(
|
||||||
event
|
event
|
||||||
.format_shorthand()
|
.format_shorthand()
|
||||||
@@ -1249,6 +1343,7 @@ mod tests {
|
|||||||
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
|
assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web");
|
||||||
assert_eq!(json["unmapped"]["service_name"], "web");
|
assert_eq!(json["unmapped"]["service_name"], "web");
|
||||||
assert_eq!(json["unmapped"]["target_port"], 8080);
|
assert_eq!(json["unmapped"]["target_port"], 8080);
|
||||||
|
assert_eq!(json["unmapped"]["authorization_mode"], "strip");
|
||||||
assert!(
|
assert!(
|
||||||
event
|
event
|
||||||
.format_shorthand()
|
.format_shorthand()
|
||||||
@@ -1309,7 +1404,8 @@ mod tests {
|
|||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.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_eq!(upstream.uri(), "/path");
|
||||||
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
|
assert!(!upstream.headers().contains_key(header::AUTHORIZATION));
|
||||||
@@ -1322,6 +1418,145 @@ mod tests {
|
|||||||
assert_eq!(upstream.headers()["x-app-header"], "kept");
|
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]
|
#[test]
|
||||||
fn detects_websocket_upgrade_request() {
|
fn detects_websocket_upgrade_request() {
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
@@ -1346,7 +1581,8 @@ mod tests {
|
|||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.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.uri(), "/chat?session=main");
|
||||||
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
|
assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade");
|
||||||
@@ -1355,6 +1591,30 @@ mod tests {
|
|||||||
assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080");
|
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]
|
#[tokio::test]
|
||||||
async fn load_endpoint_uses_workspace_for_lookup() {
|
async fn load_endpoint_uses_workspace_for_lookup() {
|
||||||
let store = crate::persistence::test_store().await;
|
let store = crate::persistence::test_store().await;
|
||||||
@@ -1375,6 +1635,7 @@ mod tests {
|
|||||||
name: "web".to_string(),
|
name: "web".to_string(),
|
||||||
target_port: 8080,
|
target_port: 8080,
|
||||||
domain: true,
|
domain: true,
|
||||||
|
authorization_mode: ServiceAuthorizationMode::Strip as i32,
|
||||||
};
|
};
|
||||||
store.put_message(&ep).await.unwrap();
|
store.put_message(&ep).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -126,15 +126,19 @@ mod tests {
|
|||||||
// inventories; the provider-environment file map is public-only. The
|
// inventories; the provider-environment file map is public-only. The
|
||||||
// request has no provider-file capability field: older supervisors ignore
|
// request has no provider-file capability field: older supervisors ignore
|
||||||
// the additive file map while retaining the rest of the response.
|
// 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 =
|
const PUBLIC_RPC_SCHEMA_SHA256: &str =
|
||||||
"2ed66dbc38c60eb96c7461c76d02813c177facfad93753b180534477270ad240";
|
"2e156c6ad3c8eb51bcd30dc13b173fe339b38207a1b1f98f7be2e0cad8e3bd45";
|
||||||
const DURABLE_SCHEMA_SHA256: &str =
|
const DURABLE_SCHEMA_SHA256: &str =
|
||||||
"399737f2a367d2e3a9d78cf84e2a97eef041835554599790788e4bbf318116c3";
|
"38165d9d76f49fcfe98a12f241e032838a2376c1d1a87ea2796fd33b9b1a3541";
|
||||||
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
const PUBLIC_DURABLE_OVERLAP_SHA256: &str =
|
||||||
"d3c444ecdb42306af8a81791481fdfc147ddc54bf344e1c8e69bd06745c6cc3c";
|
"761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3";
|
||||||
// A persisted Sandbox without endpoint status retains its lifecycle fields;
|
// A persisted Sandbox without endpoint status retains its lifecycle fields;
|
||||||
// the absent repeated field decodes empty and needs no database rewrite.
|
// the absent repeated field decodes empty and needs no database rewrite.
|
||||||
const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964";
|
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,
|
// Synthetic payloads generated with the public declarations at v0.0.116,
|
||||||
// before their relocation into openshell.storage.v1. Values are deliberately
|
// before their relocation into openshell.storage.v1. Values are deliberately
|
||||||
// non-secret and the ordinary protobuf bytes contain no package names.
|
// non-secret and the ordinary protobuf bytes contain no package names.
|
||||||
@@ -594,9 +598,9 @@ mod tests {
|
|||||||
overlap_hash.as_str(),
|
overlap_hash.as_str(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
(306, 26),
|
(306, 27),
|
||||||
(93, 20),
|
(93, 21),
|
||||||
(81, 20),
|
(81, 21),
|
||||||
PUBLIC_RPC_SCHEMA_SHA256,
|
PUBLIC_RPC_SCHEMA_SHA256,
|
||||||
DURABLE_SCHEMA_SHA256,
|
DURABLE_SCHEMA_SHA256,
|
||||||
PUBLIC_DURABLE_OVERLAP_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]
|
#[test]
|
||||||
fn pre_readiness_sandbox_spec_decodes_with_initial_attachment_epoch() {
|
fn pre_readiness_sandbox_spec_decodes_with_initial_attachment_epoch() {
|
||||||
let spec = openshell_core::proto::SandboxSpec::decode(
|
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:
|
map keyed by those names; use the empty key for the unnamed endpoint:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from openshell import SandboxClient, ServiceExposure
|
from openshell import SandboxClient, ServiceAuthorizationMode, ServiceExposure
|
||||||
|
|
||||||
with SandboxClient.from_active_cluster() as client:
|
with SandboxClient.from_active_cluster() as client:
|
||||||
sandbox = client.create(
|
sandbox = client.create(
|
||||||
workspace="default",
|
workspace="default",
|
||||||
name="app-server",
|
name="app-server",
|
||||||
service_exposures=[ServiceExposure(target_port=4500)],
|
service_exposures=[ServiceExposure(
|
||||||
|
target_port=4500,
|
||||||
|
authorization_mode=ServiceAuthorizationMode.BEARER_PASSTHROUGH,
|
||||||
|
)],
|
||||||
)
|
)
|
||||||
print(sandbox.service_urls[""])
|
print(sandbox.service_urls[""])
|
||||||
```
|
```
|
||||||
@@ -493,7 +496,10 @@ with SandboxClient.from_active_cluster() as client:
|
|||||||
const sandbox = await client.sandbox.create({
|
const sandbox = await client.sandbox.create({
|
||||||
name: 'app-server',
|
name: 'app-server',
|
||||||
image: 'base',
|
image: 'base',
|
||||||
serviceExposures: [{ targetPort: 4500 }],
|
serviceExposures: [{
|
||||||
|
targetPort: 4500,
|
||||||
|
authorizationMode: ServiceAuthorizationMode.BearerPassthrough,
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
console.log(sandbox.serviceUrls[''])
|
console.log(sandbox.serviceUrls[''])
|
||||||
```
|
```
|
||||||
@@ -502,7 +508,10 @@ console.log(sandbox.serviceUrls[''])
|
|||||||
sandbox, err := client.Sandboxes().Create(
|
sandbox, err := client.Sandboxes().Create(
|
||||||
ctx, "default", "app-server", spec, nil,
|
ctx, "default", "app-server", spec, nil,
|
||||||
v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{
|
v1.CreateOptions{ServiceExposures: []v1.ServiceExposure{
|
||||||
{TargetPort: 4500},
|
{
|
||||||
|
TargetPort: 4500,
|
||||||
|
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
)
|
)
|
||||||
fmt.Println(sandbox.ServiceURLs[""])
|
fmt.Println(sandbox.ServiceURLs[""])
|
||||||
@@ -514,6 +523,7 @@ let sandbox = client.create_sandbox(openshell_sdk::SandboxSpec {
|
|||||||
service_exposures: vec![openshell_sdk::ServiceExposure {
|
service_exposures: vec![openshell_sdk::ServiceExposure {
|
||||||
service: String::new(),
|
service: String::new(),
|
||||||
target_port: 4500,
|
target_port: 4500,
|
||||||
|
authorization_mode: openshell_sdk::ServiceAuthorizationMode::BearerPassthrough,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}).await?;
|
}).await?;
|
||||||
@@ -532,6 +542,63 @@ Pass an optional service name to create a named service URL:
|
|||||||
openshell service expose my-sandbox 8080 web
|
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:
|
List exposed endpoints:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -552,7 +619,8 @@ openshell service list my-sandbox --output yaml
|
|||||||
```
|
```
|
||||||
|
|
||||||
Structured list output contains `services` and `next_page_token` fields. Each
|
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
|
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.
|
`--page-token` to continue. An empty result has an empty `services` collection.
|
||||||
|
|
||||||
@@ -571,7 +639,14 @@ openshell service delete my-sandbox
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Note>
|
<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>
|
</Note>
|
||||||
|
|
||||||
## Monitor and Debug
|
## Monitor and Debug
|
||||||
|
|||||||
Generated
+164
-7
@@ -38,7 +38,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"untrusted",
|
"untrusted 0.7.1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -814,7 +814,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -902,6 +902,8 @@ dependencies = [
|
|||||||
"noyalib",
|
"noyalib",
|
||||||
"prost",
|
"prost",
|
||||||
"rand",
|
"rand",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serial_test",
|
"serial_test",
|
||||||
@@ -909,6 +911,7 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tonic-prost",
|
"tonic-prost",
|
||||||
@@ -1111,6 +1114,20 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -1127,7 +1144,52 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"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]]
|
[[package]]
|
||||||
@@ -1317,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1326,6 +1388,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1375,7 +1443,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1452,7 +1520,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1466,6 +1534,16 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -1617,6 +1695,12 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1738,6 +1822,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -1747,6 +1840,70 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ name = "custom_image"
|
|||||||
path = "tests/custom_image.rs"
|
path = "tests/custom_image.rs"
|
||||||
required-features = ["e2e-docker"]
|
required-features = ["e2e-docker"]
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "service_bearer_passthrough"
|
||||||
|
path = "tests/service_bearer_passthrough.rs"
|
||||||
|
required-features = ["e2e-docker"]
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "rootfs_tar"
|
name = "rootfs_tar"
|
||||||
path = "tests/rootfs_tar.rs"
|
path = "tests/rootfs_tar.rs"
|
||||||
@@ -240,11 +245,14 @@ sha1 = "0.10"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
rand = "0.9"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
|
serde_yml = { package = "noyalib", version = "0.0.28", default-features = false, features = ["std", "compat-serde-yaml"] }
|
||||||
tonic = { version = "0.14", features = ["transport"] }
|
tonic = { version = "0.14", features = ["transport"] }
|
||||||
tonic-prost = "0.14"
|
tonic-prost = "0.14"
|
||||||
|
tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "tls12", "aws_lc_rs"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
url = "2"
|
url = "2"
|
||||||
nix = { version = "0.29", features = ["process", "signal", "term", "user"] }
|
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> {
|
async fn spawn(&mut self, base: &str, tls_directory: &Path) -> Result<String, String> {
|
||||||
let tls_directory = tls_directory
|
let tls_directory = tls_directory
|
||||||
.to_str()
|
.to_str()
|
||||||
.filter(|path| !path.contains([',', '\n', '\r']))
|
.filter(|path| !path.contains([':', '\n', '\r']))
|
||||||
.ok_or("fixture TLS mount path is invalid")?;
|
.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 namespace_label = format!("openshell.ai/sandbox-namespace={}", self.namespace);
|
||||||
let mut command = Command::from(self.engine.command());
|
let mut command = Command::from(self.engine.command());
|
||||||
command
|
command
|
||||||
@@ -501,7 +504,7 @@ impl Backend {
|
|||||||
"--read-only",
|
"--read-only",
|
||||||
"--cap-drop=ALL",
|
"--cap-drop=ALL",
|
||||||
"--security-opt=no-new-privileges:true",
|
"--security-opt=no-new-privileges:true",
|
||||||
"--mount",
|
"--volume",
|
||||||
&mount,
|
&mount,
|
||||||
"--entrypoint",
|
"--entrypoint",
|
||||||
"/usr/bin/python3",
|
"/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
|
// Optional nonzero UUID for durable at-most-once admission. Successful results
|
||||||
// can be replayed for 24 hours; see the API errors and retries reference.
|
// can be replayed for 24 hours; see the API errors and retries reference.
|
||||||
string request_id = 6;
|
string request_id = 6;
|
||||||
|
// Application authorization behavior. Omission resolves to STRIP.
|
||||||
|
ServiceAuthorizationMode authorization_mode = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request to fetch an exposed sandbox service endpoint.
|
// Request to fetch an exposed sandbox service endpoint.
|
||||||
@@ -1840,6 +1842,8 @@ message ServiceEndpoint {
|
|||||||
uint32 target_port = 5;
|
uint32 target_port = 5;
|
||||||
// Whether browser-facing service routing is enabled for this endpoint.
|
// Whether browser-facing service routing is enabled for this endpoint.
|
||||||
bool domain = 6;
|
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.
|
// Response containing a service endpoint and, when available, its local URL.
|
||||||
@@ -3785,4 +3789,17 @@ message SandboxServiceExposure {
|
|||||||
string service = 1;
|
string service = 1;
|
||||||
// Loopback TCP port inside the sandbox.
|
// Loopback TCP port inside the sandbox.
|
||||||
uint32 target_port = 2;
|
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,
|
SandboxStatusRef,
|
||||||
SandboxTemplateClient,
|
SandboxTemplateClient,
|
||||||
SandboxWorkloadTemplateProvenanceRef,
|
SandboxWorkloadTemplateProvenanceRef,
|
||||||
|
ServiceAuthorizationMode,
|
||||||
ServiceExposure,
|
ServiceExposure,
|
||||||
TlsConfig,
|
TlsConfig,
|
||||||
WorkspaceClient,
|
WorkspaceClient,
|
||||||
@@ -53,6 +54,7 @@ __all__ = [
|
|||||||
"SandboxStatusRef",
|
"SandboxStatusRef",
|
||||||
"SandboxTemplateClient",
|
"SandboxTemplateClient",
|
||||||
"SandboxWorkloadTemplateProvenanceRef",
|
"SandboxWorkloadTemplateProvenanceRef",
|
||||||
|
"ServiceAuthorizationMode",
|
||||||
"ServiceExposure",
|
"ServiceExposure",
|
||||||
"TlsConfig",
|
"TlsConfig",
|
||||||
"WorkspaceClient",
|
"WorkspaceClient",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from enum import IntEnum
|
||||||
from typing import TYPE_CHECKING, Any, Generic, Never, SupportsIndex, TypeVar, cast
|
from typing import TYPE_CHECKING, Any, Generic, Never, SupportsIndex, TypeVar, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -115,6 +116,12 @@ def _service_exposure_messages(
|
|||||||
openshell_pb2.SandboxServiceExposure(
|
openshell_pb2.SandboxServiceExposure(
|
||||||
service=exposure.service,
|
service=exposure.service,
|
||||||
target_port=exposure.target_port,
|
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 ()
|
for exposure in exposures or ()
|
||||||
]
|
]
|
||||||
@@ -453,12 +460,20 @@ class SandboxStatusRef:
|
|||||||
main_process_started_at_ms: int | None = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class ServiceExposure:
|
class ServiceExposure:
|
||||||
"""A loopback HTTP service to expose during sandbox creation."""
|
"""A loopback HTTP service to expose during sandbox creation."""
|
||||||
|
|
||||||
target_port: int
|
target_port: int
|
||||||
service: str = ""
|
service: str = ""
|
||||||
|
authorization_mode: ServiceAuthorizationMode = ServiceAuthorizationMode.STRIP
|
||||||
|
|
||||||
|
|
||||||
class _ImmutableLabels(dict[str, str]):
|
class _ImmutableLabels(dict[str, str]):
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from openshell.sandbox import (
|
|||||||
SandboxRef,
|
SandboxRef,
|
||||||
SandboxStatusRef,
|
SandboxStatusRef,
|
||||||
SandboxTemplateClient,
|
SandboxTemplateClient,
|
||||||
|
ServiceAuthorizationMode,
|
||||||
ServiceExposure,
|
ServiceExposure,
|
||||||
TlsConfig,
|
TlsConfig,
|
||||||
_atomic_replace,
|
_atomic_replace,
|
||||||
@@ -2219,15 +2220,26 @@ def test_create_forwards_service_exposures() -> None:
|
|||||||
name="app-server",
|
name="app-server",
|
||||||
service_exposures=[
|
service_exposures=[
|
||||||
ServiceExposure(target_port=4500),
|
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 stub.create_request is not None
|
||||||
assert [
|
assert [
|
||||||
(exposure.service, exposure.target_port)
|
(exposure.service, exposure.target_port, exposure.authorization_mode)
|
||||||
for exposure in stub.create_request.service_exposures
|
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) == {
|
assert dict(ref.service_urls) == {
|
||||||
"": "https://.example.test/",
|
"": "https://.example.test/",
|
||||||
"metrics": "https://metrics.example.test/",
|
"metrics": "https://metrics.example.test/",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expose returns Unimplemented.
|
// 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() {
|
if c.closedFunc() {
|
||||||
return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"}
|
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.Name = ep.GetName()
|
||||||
result.TargetPort = ep.GetTargetPort()
|
result.TargetPort = ep.GetTargetPort()
|
||||||
result.Domain = ep.GetDomain()
|
result.Domain = ep.GetDomain()
|
||||||
|
result.AuthorizationMode = serviceAuthorizationModeFromProto(ep.GetAuthorizationMode())
|
||||||
|
|
||||||
if m := ep.GetMetadata(); m != nil {
|
if m := ep.GetMetadata(); m != nil {
|
||||||
result.ID = m.GetId()
|
result.ID = m.GetId()
|
||||||
@@ -48,12 +49,27 @@ func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointRespon
|
|||||||
Id: se.ID,
|
Id: se.ID,
|
||||||
Workspace: se.Workspace,
|
Workspace: se.Workspace,
|
||||||
},
|
},
|
||||||
SandboxId: se.SandboxID,
|
SandboxId: se.SandboxID,
|
||||||
Sandbox: se.Sandbox,
|
Sandbox: se.Sandbox,
|
||||||
Name: se.Name,
|
Name: se.Name,
|
||||||
TargetPort: se.TargetPort,
|
TargetPort: se.TargetPort,
|
||||||
Domain: se.Domain,
|
Domain: se.Domain,
|
||||||
|
AuthorizationMode: serviceAuthorizationModeToProto(se.AuthorizationMode),
|
||||||
},
|
},
|
||||||
Url: se.URL,
|
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{
|
Metadata: &dm.ObjectMeta{
|
||||||
Id: "svc-1",
|
Id: "svc-1",
|
||||||
},
|
},
|
||||||
SandboxId: "sb-1",
|
SandboxId: "sb-1",
|
||||||
Sandbox: "my-sandbox",
|
Sandbox: "my-sandbox",
|
||||||
Name: "http-server",
|
Name: "http-server",
|
||||||
TargetPort: 8080,
|
TargetPort: 8080,
|
||||||
Domain: true,
|
Domain: true,
|
||||||
|
AuthorizationMode: pb.ServiceAuthorizationMode_SERVICE_AUTHORIZATION_MODE_BEARER_PASSTHROUGH,
|
||||||
},
|
},
|
||||||
Url: "https://svc-1.example.com",
|
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, "http-server", se.Name)
|
||||||
assert.Equal(t, uint32(8080), se.TargetPort)
|
assert.Equal(t, uint32(8080), se.TargetPort)
|
||||||
assert.True(t, se.Domain)
|
assert.True(t, se.Domain)
|
||||||
|
assert.Equal(t, v1.ServiceAuthorizationModeBearerPassthrough, se.AuthorizationMode)
|
||||||
assert.Equal(t, "https://svc-1.example.com", se.URL)
|
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) {
|
func TestServiceEndpointToProto(t *testing.T) {
|
||||||
se := &v1.ServiceEndpoint{
|
se := &v1.ServiceEndpoint{
|
||||||
ID: "svc-1",
|
ID: "svc-1",
|
||||||
SandboxID: "sb-1",
|
SandboxID: "sb-1",
|
||||||
Sandbox: "my-sandbox",
|
Sandbox: "my-sandbox",
|
||||||
Name: "http-server",
|
Name: "http-server",
|
||||||
TargetPort: 8080,
|
TargetPort: 8080,
|
||||||
Domain: true,
|
Domain: true,
|
||||||
URL: "https://svc-1.example.com",
|
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||||
|
URL: "https://svc-1.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := ServiceEndpointToProto(se)
|
resp := ServiceEndpointToProto(se)
|
||||||
@@ -98,6 +101,7 @@ func TestServiceEndpointToProto(t *testing.T) {
|
|||||||
assert.Equal(t, "http-server", resp.Endpoint.Name)
|
assert.Equal(t, "http-server", resp.Endpoint.Name)
|
||||||
assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort)
|
assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort)
|
||||||
assert.True(t, resp.Endpoint.Domain)
|
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)
|
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) {
|
func TestServiceEndpointRoundTrip(t *testing.T) {
|
||||||
original := &v1.ServiceEndpoint{
|
original := &v1.ServiceEndpoint{
|
||||||
ID: "svc-rt",
|
ID: "svc-rt",
|
||||||
SandboxID: "sb-rt",
|
SandboxID: "sb-rt",
|
||||||
Sandbox: "round-trip",
|
Sandbox: "round-trip",
|
||||||
Name: "web",
|
Name: "web",
|
||||||
TargetPort: 9090,
|
TargetPort: 9090,
|
||||||
Domain: false,
|
Domain: false,
|
||||||
URL: "http://localhost:9090",
|
AuthorizationMode: v1.ServiceAuthorizationModeBearerPassthrough,
|
||||||
|
URL: "http://localhost:9090",
|
||||||
}
|
}
|
||||||
|
|
||||||
proto := ServiceEndpointToProto(original)
|
proto := ServiceEndpointToProto(original)
|
||||||
@@ -127,5 +132,6 @@ func TestServiceEndpointRoundTrip(t *testing.T) {
|
|||||||
assert.Equal(t, original.Name, back.Name)
|
assert.Equal(t, original.Name, back.Name)
|
||||||
assert.Equal(t, original.TargetPort, back.TargetPort)
|
assert.Equal(t, original.TargetPort, back.TargetPort)
|
||||||
assert.Equal(t, original.Domain, back.Domain)
|
assert.Equal(t, original.Domain, back.Domain)
|
||||||
|
assert.Equal(t, original.AuthorizationMode, back.AuthorizationMode)
|
||||||
assert.Equal(t, original.URL, back.URL)
|
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))
|
result := make([]*pb.SandboxServiceExposure, 0, len(exposures))
|
||||||
for _, exposure := range exposures {
|
for _, exposure := range exposures {
|
||||||
result = append(result, &pb.SandboxServiceExposure{
|
result = append(result, &pb.SandboxServiceExposure{
|
||||||
Service: exposure.Service,
|
Service: exposure.Service,
|
||||||
TargetPort: exposure.TargetPort,
|
TargetPort: exposure.TargetPort,
|
||||||
|
AuthorizationMode: serviceAuthorizationModeToProto(exposure.AuthorizationMode),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
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 {
|
func validateTemplateCreateSpec(spec *SandboxSpec) error {
|
||||||
if spec == nil {
|
if spec == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -318,7 +318,11 @@ func TestSandboxCreate(t *testing.T) {
|
|||||||
labels,
|
labels,
|
||||||
CreateOptions{ServiceExposures: []ServiceExposure{
|
CreateOptions{ServiceExposures: []ServiceExposure{
|
||||||
{TargetPort: 4500},
|
{TargetPort: 4500},
|
||||||
{Service: "metrics", TargetPort: 9090},
|
{
|
||||||
|
Service: "metrics",
|
||||||
|
TargetPort: 9090,
|
||||||
|
AuthorizationMode: ServiceAuthorizationModeBearerPassthrough,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,7 +338,9 @@ func TestSandboxCreate(t *testing.T) {
|
|||||||
}, result.ServiceURLs)
|
}, result.ServiceURLs)
|
||||||
require.Len(t, mock.createRequest.GetServiceExposures(), 2)
|
require.Len(t, mock.createRequest.GetServiceExposures(), 2)
|
||||||
assert.Equal(t, uint32(4500), mock.createRequest.GetServiceExposures()[0].GetTargetPort())
|
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, "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) {
|
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.
|
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
|
||||||
type ServiceExposure = types.ServiceExposure
|
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.
|
// ServiceInterface defines operations for managing sandbox service endpoints.
|
||||||
type ServiceInterface interface {
|
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)
|
Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error)
|
||||||
List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error)
|
List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error)
|
||||||
ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*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)}
|
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{
|
resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{
|
||||||
Sandbox: sandboxName,
|
Sandbox: sandboxName,
|
||||||
WorkspaceScope: namedWorkspaceScope(workspace),
|
WorkspaceScope: namedWorkspaceScope(workspace),
|
||||||
Name: serviceName,
|
Name: serviceName,
|
||||||
TargetPort: targetPort,
|
TargetPort: targetPort,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
|
AuthorizationMode: pb.ServiceAuthorizationMode(authorizationMode),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, converter.FromGRPCError(err)
|
return nil, converter.FromGRPCError(err)
|
||||||
|
|||||||
@@ -55,10 +55,11 @@ func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServi
|
|||||||
Metadata: &dm.ObjectMeta{
|
Metadata: &dm.ObjectMeta{
|
||||||
Id: "ep-" + req.GetName(),
|
Id: "ep-" + req.GetName(),
|
||||||
},
|
},
|
||||||
Sandbox: req.GetSandbox(),
|
Sandbox: req.GetSandbox(),
|
||||||
Name: req.GetName(),
|
Name: req.GetName(),
|
||||||
TargetPort: req.GetTargetPort(),
|
TargetPort: req.GetTargetPort(),
|
||||||
Domain: req.GetDomain(),
|
Domain: req.GetDomain(),
|
||||||
|
AuthorizationMode: req.GetAuthorizationMode(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if req.GetDomain() {
|
if req.GetDomain() {
|
||||||
@@ -150,7 +151,15 @@ func TestServiceExpose(t *testing.T) {
|
|||||||
client, cleanup := setupServiceTest(t, mock)
|
client, cleanup := setupServiceTest(t, mock)
|
||||||
defer cleanup()
|
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.NoError(t, err)
|
||||||
require.NotNil(t, ep)
|
require.NotNil(t, ep)
|
||||||
@@ -159,6 +168,7 @@ func TestServiceExpose(t *testing.T) {
|
|||||||
assert.Equal(t, "api", ep.Name)
|
assert.Equal(t, "api", ep.Name)
|
||||||
assert.Equal(t, uint32(8080), ep.TargetPort)
|
assert.Equal(t, uint32(8080), ep.TargetPort)
|
||||||
assert.True(t, ep.Domain)
|
assert.True(t, ep.Domain)
|
||||||
|
assert.Equal(t, ServiceAuthorizationModeBearerPassthrough, ep.AuthorizationMode)
|
||||||
assert.Equal(t, "https://api.example.com", ep.URL)
|
assert.Equal(t, "https://api.example.com", ep.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,32 @@
|
|||||||
|
|
||||||
package types
|
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.
|
// ServiceExposure describes a loopback HTTP service to expose during sandbox creation.
|
||||||
type ServiceExposure struct {
|
type ServiceExposure struct {
|
||||||
Service string
|
Service string
|
||||||
TargetPort uint32
|
TargetPort uint32
|
||||||
|
AuthorizationMode ServiceAuthorizationMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceEndpoint represents an exposed HTTP service on a sandbox.
|
// ServiceEndpoint represents an exposed HTTP service on a sandbox.
|
||||||
type ServiceEndpoint struct {
|
type ServiceEndpoint struct {
|
||||||
ID string
|
ID string
|
||||||
SandboxID string
|
SandboxID string
|
||||||
Sandbox string
|
Sandbox string
|
||||||
Name string
|
Name string
|
||||||
TargetPort uint32
|
TargetPort uint32
|
||||||
Domain bool
|
Domain bool
|
||||||
URL string
|
URL string
|
||||||
Workspace string
|
Workspace string
|
||||||
|
AuthorizationMode ServiceAuthorizationMode
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,16 @@ import {
|
|||||||
SandboxClient,
|
SandboxClient,
|
||||||
SandboxTemplateClient,
|
SandboxTemplateClient,
|
||||||
SCOPE_NAMES,
|
SCOPE_NAMES,
|
||||||
|
ServiceAuthorizationMode,
|
||||||
STATUS_NAMES,
|
STATUS_NAMES,
|
||||||
} from './client.js';
|
} 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 { PolicySource, SettingScope } from './gen/sandbox_pb.js';
|
||||||
import type { ExecInteractiveSession, ExecInteractiveSessionControl } from './index.js';
|
import type { ExecInteractiveSession, ExecInteractiveSessionControl } from './index.js';
|
||||||
|
|
||||||
@@ -276,7 +283,9 @@ describe('exec / execStream', () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('sends create-time service exposures', async () => {
|
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({
|
const sandbox = client({
|
||||||
createSandbox: (req) => {
|
createSandbox: (req) => {
|
||||||
created = req;
|
created = req;
|
||||||
@@ -292,12 +301,29 @@ describe('create', () => {
|
|||||||
|
|
||||||
const result = await sandbox.create({
|
const result = await sandbox.create({
|
||||||
image: 'img',
|
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([
|
expect(
|
||||||
{ service: '', targetPort: 4500 },
|
created.serviceExposures?.map(({ service, targetPort, authorizationMode }) => ({
|
||||||
{ service: 'metrics', targetPort: 9090 },
|
service,
|
||||||
|
targetPort,
|
||||||
|
authorizationMode,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ service: '', targetPort: 4500, authorizationMode: ProtoServiceAuthorizationMode.STRIP },
|
||||||
|
{
|
||||||
|
service: 'metrics',
|
||||||
|
targetPort: 9090,
|
||||||
|
authorizationMode: ProtoServiceAuthorizationMode.BEARER_PASSTHROUGH,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.serviceUrls).toEqual({
|
expect(result.serviceUrls).toEqual({
|
||||||
'': 'https://sb.example.test/',
|
'': 'https://sb.example.test/',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './g
|
|||||||
import {
|
import {
|
||||||
type ExecSandboxInputSchema,
|
type ExecSandboxInputSchema,
|
||||||
OpenShell,
|
OpenShell,
|
||||||
|
ServiceAuthorizationMode as ProtoServiceAuthorizationMode,
|
||||||
SandboxPhase,
|
SandboxPhase,
|
||||||
SandboxRestartPolicy,
|
SandboxRestartPolicy,
|
||||||
type SandboxSpecSchema,
|
type SandboxSpecSchema,
|
||||||
@@ -168,6 +169,23 @@ export interface ServiceExposure {
|
|||||||
service?: string;
|
service?: string;
|
||||||
/** Loopback TCP port inside the sandbox. */
|
/** Loopback TCP port inside the sandbox. */
|
||||||
targetPort: number;
|
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 {
|
export interface SandboxFromTemplateSpec {
|
||||||
@@ -1023,6 +1041,7 @@ export class SandboxClient {
|
|||||||
spec.serviceExposures?.map((exposure) => ({
|
spec.serviceExposures?.map((exposure) => ({
|
||||||
service: exposure.service ?? '',
|
service: exposure.service ?? '',
|
||||||
targetPort: exposure.targetPort,
|
targetPort: exposure.targetPort,
|
||||||
|
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
});
|
});
|
||||||
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
||||||
@@ -1049,6 +1068,7 @@ export class SandboxClient {
|
|||||||
spec.serviceExposures?.map((exposure) => ({
|
spec.serviceExposures?.map((exposure) => ({
|
||||||
service: exposure.service ?? '',
|
service: exposure.service ?? '',
|
||||||
targetPort: exposure.targetPort,
|
targetPort: exposure.targetPort,
|
||||||
|
authorizationMode: serviceAuthorizationModeToProto(exposure.authorizationMode),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
});
|
});
|
||||||
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
return sandboxRef(resp.sandbox, resp.serviceUrls);
|
||||||
|
|||||||
@@ -53,7 +53,14 @@ export type {
|
|||||||
WaitOptions,
|
WaitOptions,
|
||||||
WorkspaceListScope,
|
WorkspaceListScope,
|
||||||
} from './client.js';
|
} 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 type { ErrorInfo, FieldViolation, SdkErrorCode } from './errors.js';
|
||||||
export { fromConnect, SdkError } from './errors.js';
|
export { fromConnect, SdkError } from './errors.js';
|
||||||
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
|
export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';
|
||||||
|
|||||||
@@ -886,11 +886,13 @@ openshell sandbox create \
|
|||||||
--name my-app \
|
--name my-app \
|
||||||
--from my-app:latest \
|
--from my-app:latest \
|
||||||
--expose 8080 \
|
--expose 8080 \
|
||||||
|
--expose-authorization-mode bearer-passthrough \
|
||||||
--detach \
|
--detach \
|
||||||
-- ./start-server.sh
|
-- ./start-server.sh
|
||||||
|
|
||||||
# Expose and manage an HTTP service through the gateway.
|
# 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
|
||||||
openshell service list my-app --output json
|
openshell service list my-app --output json
|
||||||
openshell service get my-app web
|
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.
|
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.
|
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.
|
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"
|
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-sys",
|
"aws-lc-sys",
|
||||||
"untrusted",
|
"untrusted 0.7.1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -804,7 +804,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -903,12 +903,15 @@ dependencies = [
|
|||||||
"noyalib",
|
"noyalib",
|
||||||
"prost",
|
"prost",
|
||||||
"rand",
|
"rand",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tonic-prost",
|
"tonic-prost",
|
||||||
@@ -1122,6 +1125,20 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -1138,7 +1155,52 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"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]]
|
[[package]]
|
||||||
@@ -1303,7 +1365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1312,6 +1374,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1361,7 +1429,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1438,7 +1506,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1452,6 +1520,16 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
@@ -1603,6 +1681,12 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -1724,6 +1808,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -1733,6 +1826,70 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user