feat(gateway): add gRPC server reflection - #3161
2000krysztof wants to merge 2 commits into
Conversation
87b3006 to
150824f
Compare
letv1nnn
left a comment
There was a problem hiding this comment.
Reviewed against #3058 acceptance criteria — all met: reflection v1 on the primary listener, descriptor set filtered to the public services via a transitive-import closure, callback listeners reject reflection, no OIDC/mTLS user auth (transport TLS still applies), integration test against a running gateway, and grpcurl docs. Filtering by reachability (allowlist, not denylist) is the right call, and reuse of the pre-existing /grpc.reflection. unauthenticated bypass means no new auth hole is opened.
A few things I checked that are fine as-is:
- Rate limiting covers reflection.
GrpcRateLimiteris a global counter wrapping the router above auth, so anonymous reflection requests are already counted — no reflection-specific DoS gap. - Rename guard.
tonic-reflectionbuild does not validate explicitwith_service_nameagainst the descriptor pool, so a renamed root proto could advertise a phantom service. Thereflection_descriptor_excludes_internal_service_protostest asserts the root protos are present, so a rename fails CI rather than shipping silently. Adebug_assert!on the retained set ingateway_reflection_descriptor_setwould make that intent local, but the test already covers it.
One conscious accept to confirm: the full public API surface — including admin/operator RPC names and message shapes — is now discoverable unauthenticated on the primary listener. That is the documented intent of #3058; flagging only so it is a deliberate decision.
LGTM.
politerealism
left a comment
There was a problem hiding this comment.
Minor Concern: The auth exemption prefix is broader than the reflection service actually registered.
auth/oidc.rs:33's UNAUTHENTICATED_PREFIXES already exempts the general prefix "/grpc.reflection." (not version-scoped) from OIDC/mTLS-user auth . This is an entry that predates this PR. This PR wires up a real service behind REFLECTION_PATH_PREFIX = "/grpc.reflection.v1." in multiplex.rs, but doesn't tighten the auth exemption to match it.
The Risk: A future reflection variant (e.g., a v1alpha or v2 service) registered on the router would automatically inherit this blanket auth exemption without requiring changes to auth/oidc.rs, making the security implications invisible in that future diff.
Suggested Fixes:
- Tighten the string directly: Change the UNAUTHENTICATED_PREFIXES entry from "/grpc.reflection." to "/grpc.reflection.v1.". This fixes today's gap, but the two strings remain independently maintained and could drift apart.
- Share a single source of truth (Preferred): Define the reflection prefix once — e.g., pub const REFLECTION_PATH_PREFIX in multiplex.rs — and reference that same constant inside oidc.rs instead of using a hardcoded literal. That way, a future reflection variant must explicitly extend or reuse the shared constant, making its auth exposure fully visible in code review.
(I recommend number 2)
150824f to
c947f5e
Compare
|
Good catch @politerealism! I went with option 2, the concern should be addressed now. |
|
/assign |
|
/ok to test add39c3 |
add39c3 to
f8fde7f
Compare
|
/ok-to-test f8fde7f |
|
This is a good feature, but I think it would be better to build the reflection service once and clone the shared service into each connection to avoid unnecessary overhead. There’s also maybe a concern on how this impacts rate limiting: server reflection is a bidirectional streaming RPC, so the global request limiter accounts for opening the stream but not for each reflection query sent through it. A client could therefore open one unauthenticated stream and issue many descriptor requests while consuming only one unit of the global rate limit. |
|
Thanks for catching @krishicks both issues. I’ve updated the implementation to build the reflection descriptor index once at gateway startup and clone the shared service for each connection. |
8475d8b to
da35841
Compare
|
/ok-to-test da35841 |
da35841 to
18db915
Compare
|
/ok-to-test 18db915 |
Signed-off-by: Krzysztof Malczuk <kmalczuk@redhat.com>
Signed-off-by: Krzysztof Malczuk <kmalczuk@redhat.com>
18db915 to
799c99b
Compare
|
/ok-to-test 799c99b |
elezar
left a comment
There was a problem hiding this comment.
The primary-listener routing, authentication bypass, and internal-schema filtering are sensible. I am requesting changes because the custom reflection implementation returns incomplete descriptors, terminates the stream on ordinary lookup failures, and drops custom option values. Details and suggested regression tests are anchored below.
I reproduced these behaviors in an isolated Rust harness using this commit's unchanged reflection.rs and descriptors compiled from its protos, with configuration and rate-limiter stubs. Service enumeration passed. This validates reflection behavior, not the complete gateway integration; I did not run the full gateway suite.
Please also add an integration test through the production listener setup. The current running_primary_gateway_reflection_advertises_only_public_services test manually assembles AuthGrpcRouter, GrpcRouter, and MultiplexedService, so it cannot detect missing production registration or middleware wiring. The protocol tests should cover descriptor dependency completeness, recovery after a failed lookup on the same stream, and preservation of authorization and secret-field annotations.
| Some(MessageRequest::FileByFilename(name)) => { | ||
| state.file_by_name(name).map(|descriptor| { | ||
| MessageResponse::FileDescriptorResponse(FileDescriptorResponse { | ||
| file_descriptor_proto: vec![descriptor], |
There was a problem hiding this comment.
Please return the requested descriptor together with all previously unsent transitive dependencies in both file lookup branches. Before this PR, API developers supplied the matching proto files themselves; with reflection, clients relying on a complete descriptor response receive only the root file and cannot resolve its imports. For example, querying openshell.v1.OpenShell returns openshell.proto without datamodel.proto or its other imports. The reflection v1 protocol requires transitive dependencies in FileDescriptorResponse (https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto). Some clients can compensate by requesting imports individually, but the response should satisfy the protocol. I reproduced the missing datamodel.proto dependency with the unchanged implementation. Add a test that constructs a descriptor pool solely from a fresh symbol lookup response and resolves the public service and its message types; checking only the first descriptor's filename misses this defect.
| } | ||
| } | ||
| Err(status) => { | ||
| let _ = responses_tx.send(Err(status)).await; |
There was a problem hiding this comment.
Please represent ordinary query failures as ServerReflectionResponse.message_response = ErrorResponse and continue processing the stream. Reflection was unavailable before this PR; the new implementation closes the entire RPC when a requested symbol or file is absent, so a schema browser probing an unknown symbol loses subsequent valid queries on that stream. In an isolated test, a missing-symbol request followed by ListServices produced a transport-level NOT_FOUND instead of an in-band error followed by the service list. The protocol provides ErrorResponse for these query errors: https://github.com/grpc/grpc-proto/blob/master/grpc/reflection/v1/reflection.proto. Add a regression test that sends an unknown lookup and a valid lookup on the same stream and checks both responses. Quota exhaustion can remain a stream-level failure.
|
|
||
| /// Decode and filter the compiled descriptors to the public gateway schema. | ||
| pub fn gateway_reflection_descriptor_set() -> Result<FileDescriptorSet, prost::DecodeError> { | ||
| let mut descriptor_set = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET)?; |
There was a problem hiding this comment.
Please preserve custom option values when filtering and serving descriptors. Before this PR, developers using the original protos or compiled descriptor set could inspect the authorization and secret-field annotations. Decoding through prost_types::FileDescriptorSet here discards unknown extension fields, and encode_file later serializes that lossy representation, so reflection clients receive incomplete schema metadata even though options.proto is included. I verified that openshell.v1.OpenShell.Health has the openshell.options.v1.authorization option in the original compiled descriptors and loses it after gateway_reflection_descriptor_set().encode_to_vec(). Retain each included file's original descriptor bytes or use an extension-aware representation. Add regression coverage comparing authorization and secret-field option values between the source descriptors and the reflected output.
Summary
This PR adds functionality to preform reflection calls to get a list of available gRPC endpoints. The reflection call bypasses auth and can be preformed to any gateway without a mTLS or OIDC.
Related Issue
Closes #3058
Changes
Testing
cargo test -p openshell-serverDeployed a local Podman gateway and verified:
grpcurl ... listenumerates the public services.mise run pre-commitpassesUnit tests added/updated
E2E tests added/updated (if applicable)
Checklist