diff --git a/Cargo.lock b/Cargo.lock index 72f615e934..02fff0ec73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4395,6 +4395,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "toml", "tonic", + "tonic-reflection", "tower 0.5.3", "tower-http 0.6.8", "tracing", @@ -7395,6 +7396,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-types" version = "0.14.6" diff --git a/Cargo.toml b/Cargo.toml index 6936439a3b..86a5552216 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ tokio = { version = "1.43", features = ["full"] } # gRPC/Protobuf tonic = "0.14" +tonic-reflection = "0.14" tonic-prost = "0.14" tonic-prost-build = "0.14" prost = "0.14" diff --git a/README.md b/README.md index a7ecf4179e..3fa92c45ee 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,24 @@ Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall ba See the [full documentation](https://docs.nvidia.com/openshell/latest) for command guides, tutorials, and reference material. +### Test the gRPC API with grpcurl + +The gateway serves the gRPC reflection v1 protocol. After starting a local +plaintext gateway, use `grpcurl` without checking out or supplying the proto +files: + +```shell +grpcurl -plaintext localhost:18080 list +grpcurl -plaintext localhost:18080 describe openshell.v1.OpenShell +grpcurl -plaintext -d '{}' localhost:18080 openshell.v1.OpenShell/Health +``` + +The service list contains the public `openshell.v1.OpenShell` and +`openshell.inference.v1.Inference` APIs. Reflection does not advertise the +gateway's internal compute-driver, credential-driver, interceptor, or +middleware services. For a TLS gateway, omit `-plaintext` and supply the CA and +client certificate options required by the deployment. + ## Terminal UI OpenShell includes a real-time terminal dashboard for monitoring gateways, sandboxes, and providers — inspired by [k9s](https://k9scli.io/). diff --git a/architecture/gateway.md b/architecture/gateway.md index f86411511b..432c510aef 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -74,6 +74,12 @@ Docker and Podman drivers. Operator-granted listener capabilities for external drivers are tracked in [#2539](https://github.com/NVIDIA/OpenShell/issues/2539). +The primary listener serves the gRPC reflection v1 protocol without application +authentication. It advertises only the public `openshell.v1.OpenShell` and +`openshell.inference.v1.Inference` services. TLS and client-certificate +requirements still apply at the transport layer. Callback-only listeners reject +reflection before authentication. + Operators can configure a gateway-wide gRPC request rate limit. The limit is applied only to gRPC API traffic after protocol multiplexing; health, metrics, and local sandbox-service HTTP routes are not rate limited by this control. diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 898eef334d..b3b3c7378c 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -39,6 +39,7 @@ nix = { workspace = true } # gRPC tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tonic-reflection = { workspace = true } prost = { workspace = true } prost-reflect = { workspace = true } prost-types = { workspace = true } diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index 475c52ebec..83e147fc4d 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -30,7 +30,8 @@ use tracing::{debug, info, warn}; /// These are structural bypasses for gRPC infrastructure that doesn't map to a /// single RPC method. Per-method bypasses (e.g. `Health`) are declared at the /// handler with `auth_mode: "unauthenticated"` in the proto annotation. -const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; +const UNAUTHENTICATED_PREFIXES: &[&str] = + &[crate::multiplex::REFLECTION_PATH_PREFIX, "/grpc.health."]; /// Returns `true` if the method needs no authentication at all. pub fn is_unauthenticated_method(path: &str) -> bool { @@ -407,10 +408,13 @@ mod tests { #[test] fn reflection_is_unauthenticated() { assert!(is_unauthenticated_method( + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + )); + assert!(!is_unauthenticated_method( "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo" )); - assert!(is_unauthenticated_method( - "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + assert!(!is_unauthenticated_method( + "/grpc.reflection.v2.ServerReflection/ServerReflectionInfo" )); } diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 3ef774e4cb..390ef5de5f 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -30,6 +30,7 @@ use opentelemetry::propagation::TextMapPropagator; use opentelemetry::trace::TraceContextExt as _; use opentelemetry_sdk::propagation::TraceContextPropagator; use prost::Message; +use prost_types::FileDescriptorSet; use std::collections::BTreeMap; use std::convert::Infallible; use std::future::Future; @@ -204,6 +205,39 @@ macro_rules! request_id_middleware { /// the largest payload and well within this cap under normal use. const MAX_GRPC_DECODE_SIZE: usize = 1_048_576; const MAX_INTERCEPTED_GRPC_BODY_SIZE: usize = MAX_GRPC_DECODE_SIZE + 5; +const REFLECTED_PROTO_ROOTS: &[&str] = &["openshell.proto", "inference.proto"]; + +/// Restrict reflection to the public gateway APIs and their imported types. +fn gateway_reflection_descriptor_set() -> Result { + let mut descriptor_set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET)?; + let mut included: std::collections::BTreeSet = REFLECTED_PROTO_ROOTS + .iter() + .map(|name| (*name).to_string()) + .collect(); + + loop { + let before = included.len(); + for file in &descriptor_set.file { + if file + .name + .as_ref() + .is_some_and(|name| included.contains(name)) + { + included.extend(file.dependency.iter().cloned()); + } + } + if included.len() == before { + break; + } + } + + descriptor_set.file.retain(|file| { + file.name + .as_ref() + .is_some_and(|name| included.contains(name)) + }); + Ok(descriptor_set) +} /// Multiplexed gRPC/HTTP service. #[derive(Clone)] @@ -279,6 +313,11 @@ impl MultiplexService { ); let inference = InferenceServer::new(InferenceService::new(self.state.clone())) .max_decoding_message_size(MAX_GRPC_DECODE_SIZE); + let reflection = tonic_reflection::server::Builder::configure() + .register_file_descriptor_set(gateway_reflection_descriptor_set()?) + .with_service_name("openshell.v1.OpenShell") + .with_service_name("openshell.inference.v1.Inference") + .build_v1()?; let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), @@ -286,7 +325,7 @@ impl MultiplexService { }); let authenticator_chain = build_authenticator_chain(&self.state); let grpc_service = AuthGrpcRouter::with_peer_identity( - GrpcRouter::new(openshell, inference), + GrpcRouter::new(openshell, inference, reflection), authenticator_chain, authz_policy, self.state @@ -920,26 +959,29 @@ where } } -/// Combined gRPC service that routes between `OpenShell` and Inference services -/// based on the request path prefix. +/// Combined gRPC service that routes between `OpenShell`, Inference, and +/// reflection services based on the request path prefix. #[derive(Clone)] -pub struct GrpcRouter { +pub struct GrpcRouter { openshell: N, inference: I, + reflection: R, } -impl GrpcRouter { - fn new(openshell: N, inference: I) -> Self { +impl GrpcRouter { + fn new(openshell: N, inference: I, reflection: R) -> Self { Self { openshell, inference, + reflection, } } } const INFERENCE_PATH_PREFIX: &str = "/openshell.inference.v1.Inference/"; +pub const REFLECTION_PATH_PREFIX: &str = "/grpc.reflection.v1."; -impl tower::Service> for GrpcRouter +impl tower::Service> for GrpcRouter where N: tower::Service> + Clone + Send + 'static, N::Response: Send, @@ -950,6 +992,11 @@ where + Send + 'static, I::Future: Send, + R: tower::Service, Response = N::Response, Error = N::Error> + + Clone + + Send + + 'static, + R::Future: Send, B: Send + 'static, { type Response = N::Response; @@ -961,11 +1008,14 @@ where } fn call(&mut self, req: Request) -> Self::Future { - let is_inference = req.uri().path().starts_with(INFERENCE_PATH_PREFIX); + let path = req.uri().path(); - if is_inference { + if path.starts_with(INFERENCE_PATH_PREFIX) { let mut svc = self.inference.clone(); Box::pin(async move { svc.ready().await?.call(req).await }) + } else if path.starts_with(REFLECTION_PATH_PREFIX) { + let mut svc = self.reflection.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) } else { let mut svc = self.openshell.clone(); Box::pin(async move { svc.ready().await?.call(req).await }) @@ -2598,6 +2648,164 @@ mod tests { assert_eq!(grpc_method_from_path(""), ""); } + #[tokio::test] + async fn grpc_router_dispatches_gateway_inference_and_reflection_paths() { + #[derive(Clone)] + struct RouteRecorder { + name: &'static str, + calls: Arc>>, + } + + impl Service> for RouteRecorder { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + self.calls.lock().unwrap().push(self.name); + Box::pin(async { Ok(Response::new(tonic::body::Body::empty())) }) + } + } + + let calls = Arc::new(Mutex::new(Vec::new())); + let service = |name| RouteRecorder { + name, + calls: calls.clone(), + }; + let mut router = GrpcRouter::new( + service("openshell"), + service("inference"), + service("reflection"), + ); + + for path in [ + "/openshell.v1.OpenShell/Health", + "/openshell.inference.v1.Inference/GetInferenceRoute", + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", + ] { + router + .call( + Request::builder() + .uri(path) + .body(Empty::::new()) + .unwrap(), + ) + .await + .unwrap(); + } + + assert_eq!( + *calls.lock().unwrap(), + vec!["openshell", "inference", "reflection"] + ); + } + + #[tokio::test] + async fn running_primary_gateway_reflection_advertises_only_public_services() { + use crate::auth::authenticator::test_support::MockAuthenticator; + use tonic_reflection::pb::v1::{ + ServerReflectionRequest, server_reflection_client::ServerReflectionClient, + server_reflection_request::MessageRequest, server_reflection_response::MessageResponse, + }; + + let reflection = tonic_reflection::server::Builder::configure() + .register_file_descriptor_set(gateway_reflection_descriptor_set().unwrap()) + .with_service_name("openshell.v1.OpenShell") + .with_service_name("openshell.inference.v1.Inference") + .build_v1() + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let unrouted = tower::service_fn(|_request: Request| async { + Ok::<_, Infallible>(tonic::Status::unimplemented("test fallback").into_http()) + }); + let rejecting_oidc = Arc::new(MockAuthenticator::returning(Err( + tonic::Status::unauthenticated("OIDC credentials required"), + ))); + let grpc = AuthGrpcRouter::with_peer_identity( + GrpcRouter::new(unrouted, unrouted, reflection), + Some(AuthenticatorChain::new(vec![rejecting_oidc])), + None, + None, + true, + false, + ); + let service = GatewayListenerContextService::new( + MultiplexedService::new(grpc, unrouted), + GatewayListenerScope::Primary, + ); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let service = service.clone(); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await + .unwrap(); + }); + } + }); + + let channel = tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = ServerReflectionClient::new(channel); + let request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }; + let response = client + .server_reflection_info(tokio_stream::iter([request])) + .await + .unwrap() + .into_inner() + .message() + .await + .unwrap() + .unwrap(); + let Some(MessageResponse::ListServicesResponse(response)) = response.message_response + else { + panic!("expected a reflection list-services response"); + }; + let mut names: Vec<_> = response + .service + .into_iter() + .map(|service| service.name) + .collect(); + names.sort(); + + assert_eq!( + names, + vec!["openshell.inference.v1.Inference", "openshell.v1.OpenShell",] + ); + server.abort(); + } + + #[test] + fn reflection_descriptor_excludes_internal_service_protos() { + let descriptors = gateway_reflection_descriptor_set().unwrap(); + let names: std::collections::BTreeSet<_> = descriptors + .file + .iter() + .filter_map(|file| file.name.as_deref()) + .collect(); + + assert!(names.contains("openshell.proto")); + assert!(names.contains("inference.proto")); + assert!(names.contains("sandbox.proto")); + assert!(!names.contains("compute_driver.proto")); + assert!(!names.contains("credential_driver.proto")); + assert!(!names.contains("gateway_interceptor.proto")); + assert!(!names.contains("supervisor_middleware.proto")); + } + #[test] fn normalize_ws_tunnel() { assert_eq!(normalize_http_path("/_ws_tunnel"), "/_ws_tunnel"); @@ -2812,6 +3020,31 @@ mod tests { assert_eq!(grpc_status(&res).as_deref(), Some("16")); } + #[tokio::test] + async fn reflection_bypasses_oidc_and_mtls_user_authentication() { + let oidc = Arc::new(MockAuthenticator::returning(Err( + tonic::Status::unauthenticated("OIDC credentials required"), + ))); + let chain = AuthenticatorChain::new(vec![oidc]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::with_peer_identity(recorder, Some(chain), None, None, true, false); + + let res = router + .call(empty_request( + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", + )) + .await + .unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!(grpc_status(&res), None); + assert!( + seen.lock().unwrap().is_none(), + "reflection must not receive an authenticated user principal" + ); + } + #[tokio::test] async fn unauthenticated_dev_user_fills_missing_principal_when_enabled() { let mock = Arc::new(MockAuthenticator::returning(Ok(None))); diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 5969c43e64..3ee47f6346 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -68,6 +68,35 @@ The connection flow: 5. When mTLS user authentication is enabled, the gateway maps the verified certificate subject to a user principal. 6. The gateway authorizes the gRPC method. +### Inspect the API with grpcurl + +The primary gateway listener serves the gRPC reflection v1 protocol. Reflection +does not require application authentication, but the listener's TLS and client +certificate requirements still apply. + +For a local plaintext development gateway: + +```shell +grpcurl -plaintext localhost:18080 list +grpcurl -plaintext localhost:18080 describe openshell.v1.OpenShell +grpcurl -plaintext -d '{}' localhost:18080 openshell.v1.OpenShell/Health +``` + +For an mTLS gateway, use the bundle associated with the gateway: + +```shell +grpcurl \ + -cacert ~/.config/openshell/gateways//mtls/ca.crt \ + -cert ~/.config/openshell/gateways//mtls/tls.crt \ + -key ~/.config/openshell/gateways//mtls/tls.key \ + : list +``` + +Reflection advertises only `openshell.v1.OpenShell` and +`openshell.inference.v1.Inference`. It does not advertise internal driver, +interceptor, or middleware services. Callback-only compute-driver listeners do +not serve reflection. + ### OIDC Gateways can validate OpenID Connect access tokens on gRPC requests. Configure OIDC when you want users, operators, or automation to authenticate with an identity provider such as Keycloak, Entra ID, or Okta.