diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d92bc..6d45c85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,10 @@ jobs: runs-on: ubuntu-latest services: # Shared store for the rate-limit cross-instance reconciliation test. + # Pulled through Google's Docker Hub mirror: anonymous pulls from + # Docker Hub on shared runner addresses hit its rate limit. redis: - image: redis:7 + image: mirror.gcr.io/library/redis:7 ports: - 6379:6379 options: >- @@ -199,8 +201,11 @@ jobs: with: persist-credentials: false # Scans Cargo.lock for RustSec advisories; honors the ignore list in - # deny.toml. Third-party action: pinned to a commit SHA (dependabot - # keeps it fresh). - - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + # deny.toml. The released binary rather than the Docker action, whose + # image build pulls from Docker Hub and hits its anonymous rate limit + # on shared runners. + - uses: taiki-e/install-action@e407f7bafb71fd004bc5c2da3032e5470cbb6ef0 # v2 with: - command: check advisories + tool: cargo-deny + - name: Check advisories + run: cargo deny check advisories diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5d75574 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to structured-proxy + +Bug reports, fixes, documentation and new capabilities are welcome. + +## Development setup + +```bash +git clone https://github.com/structured-world/structured-proxy.git +cd structured-proxy +cargo build --features cli +``` + +Tests run under [cargo-nextest](https://nexte.st/), which is not part of the +Rust toolchain: + +```bash +cargo install cargo-nextest --locked +``` + +Before opening a pull request, run what CI runs. The crate builds with each +crypto backend, without one and with both, and the code differs between them: + +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --features cli -- -D warnings +cargo clippy --all-targets --no-default-features --features cli -- -D warnings +cargo clippy --all-targets --all-features -- -D warnings +cargo nextest run --features cli +cargo nextest run --no-default-features --features cli +cargo nextest run --all-features +cargo test --doc --all-features +``` + +CI runs these on every pull request, and three more checks that need a +particular toolchain or host. To run them yourself: + +```bash +# The minimum supported Rust version, `rust-version` in Cargo.toml. +rustup toolchain install 1.99 +cargo +1.99 check --all-targets --all-features +cargo +1.99 check --all-targets --no-default-features --features cli + +# The static musl build the release ships (Linux, with musl-tools installed). +rustup target add x86_64-unknown-linux-musl +cargo nextest run --target x86_64-unknown-linux-musl --features cli + +# The advisory check of the dependencies. +cargo install cargo-deny --locked +cargo deny check advisories +``` + +The rate-limit reconciliation test runs against Redis when +`SHIELD_REDIS_TEST_URL` names one (`redis://127.0.0.1:6379/`). + +## Pull requests + +1. Create a branch from `main`. +2. Make the change, with tests. A bug fix comes with a test that fails without + it. +3. Write commit messages and the pull request title in the + [Conventional Commits](https://www.conventionalcommits.org/) form; pull + requests are squash-merged, so the title becomes the commit on `main`. +4. A change to what the proxy serves or how it is configured updates the + README in the same pull request. + +## Contributor License Agreement (CLA) + +Before a first pull request can be merged, you sign the Structured World +Contributor License Agreement once, at . It covers +every repository of the organisation and takes a minute: sign in with GitHub, +confirm your e-mail address, sign. The `CLA` status on your pull request then +turns green by itself. + +You keep the copyright in your contribution. If you contribute as part of your +job, your employer may also need to sign the corporate agreement; the page +above explains when. + +## Security + +Do not report vulnerabilities in public issues; see [SECURITY.md](SECURITY.md). diff --git a/Cargo.toml b/Cargo.toml index dac873b..a196fe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ doc = false axum = { version = "0.8", features = ["macros"] } # `util`: the guarded gRPC and fallback paths are boxed services. tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.7", features = ["cors", "trace"] } +tower-http = { version = "0.7", features = ["trace"] } # Foundational HTTP types, used directly by the framework-agnostic embedding # hooks (src/hooks.rs) so an embedder never names `axum`. Already in the tree # transitively via both axum and tonic; pinned here to make the public API deps @@ -60,6 +60,18 @@ hyper-util = { version = "0.1", features = ["server-auto", "server-graceful", "s # in the tree through hyper-util and tonic. hyper = "1" tokio-util = { version = "0.7.16", features = ["rt"] } +# The router axum matches paths with: a transcoded template is checked with it +# before axum registers it, where a path it rejects would panic. Already in the +# tree through axum, which pins the version this resolves to. +matchit = "0.8" +# The hash of the routing maps a request is looked up in. Their keys are the +# paths and segments the proxy binds, fixed at startup, so a request can only +# look up, never fill a map with colliding keys: no randomized hash is needed, +# and FxHash looks up short strings faster than SipHash. +rustc-hash = "2" +# Decodes the path parameters of a custom verb bound on another route the +# request matches, as axum decodes its own; already in the tree through axum. +percent-encoding = "2" # gRPC client (to upstream service). `tls-connect-info` gives a rustls server # stream the connection record (client certificates) a tonic handler reads, so @@ -186,6 +198,8 @@ cli = ["dep:clap", "dep:tracing-subscriber", "redis"] # `test-util`: deadline tests run on a paused clock instead of waiting. tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } tower = { version = "0.5", features = ["util", "limit"] } +# An embedder's own CORS around the proxy (tests/edge.rs). +tower-http = { version = "0.7", features = ["cors"] } http-body-util = "0.1" # `hyper::upgrade::on`: a fallback that upgrades its connection (src/serve/tests.rs). hyper = "1" diff --git a/README.md b/README.md index f4baff5..8ce19a8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ tonic services. - REST routes from the `google.api.http` annotations in your protos: path, query and JSON or form body mapped onto the request message ([Request mapping](#request-mapping)), `response_body`, - `additional_bindings`, and `custom` rules for any HTTP method + `additional_bindings`, custom verbs (`/v1/{name}:cancel`), and `custom` + rules for any HTTP method - Server streaming as NDJSON, or Server-Sent Events when the client asks for `text/event-stream` - Errors as `google.rpc.Status` JSON with the standard HTTP status mapping and @@ -143,7 +144,9 @@ service: name: "my-service" cors: - # Empty list = permissive CORS (dev mode, reflects any Origin). + # Empty list = permissive CORS (dev mode): every origin (`*`), without + # credentials; a preflight gets back the headers it asks for, since `*` + # does not cover `Authorization`. # A non-empty list allows those exact origins, with credentials; the # preflight echoes the methods and headers the browser asks for. There is # no "*" wildcard (browsers never send `Origin: *`, so listing "*" would @@ -380,6 +383,48 @@ body, as the route's `google.api.http` rule says: A value that is not valid for its field, or two members of one `oneof`, is answered with `INVALID_ARGUMENT` (400) before the upstream is called. +A variable bound to a multi-segment template (`{name=shelves/*/books/*}`, +AIP-127) takes only a value of that shape: its literals, one segment per `*`, +any number for `**`. `**` must be the last part of the path: a template that +uses it before the last segment, or puts another segment after it, is left +out with an error in the log. Of the bindings of one router path, those such +a template or an escaped literal constrains are tried before the open ones. The value of such a variable, and of `{name=**}`, is +percent-decoded except for `%2F`, which reaches the field as received, so an +encoded slash stays distinct from a segment boundary; a single-segment +variable decodes it. A literal of such a template that holds a percent-escape +matches whatever the case of its hex digits, so to the router it is a +variable: like any variable, it cannot share its position with a `**` of +another binding, and the later of the two is left out with an error. + +**Custom verbs.** A path template may end in a verb, as AIP-136 custom methods +do (`post: "/v1/{name=operations/*}:cancel"`): + +- A request's verb is its last segment from the first unencoded colon: a + client percent-encodes the reserved characters of a variable's value, and a + verb writes its own reserved characters encoded too + (`:run%3Anow`), so neither holds a raw `:`. A percent-encoded colon (`%3A`) + in a request is part of a value, never the verb's delimiter. +- A verb some binding of the request's path binds owns that URL: only the + bindings of that verb answer it, each reaching its own RPC. A verb no + binding binds stays part of the last variable (`/v1/items/a:b` is the item + `a:b`), as Envoy's transcoder and grpc-gateway treat an unbound verb. +- A template ending in a literal matches its URL exactly and wins over a + variable with the same verb: `get: "/v1/jobs/special:cancel"` answers that + URL ahead of `post: "/v1/jobs/{name}:cancel"`, as a static route does. +- `**` before a verb may match no segment: `/v1/{name=**}:purge` answers + `/v1/:purge` with an empty `name`. +- A path with a verb after its variable answers every method, so an extra + route of yours on the same path stops the proxy at startup. +- A URL that bindings answer, but none with the request's method, is `405`, + with their methods in `Allow`. A URL that no binding answers, its field + template or verb not fitting, is no transcoded request: another route of + the proxy (an extra route of yours) takes it, else it is `404`, or your + fallback's, preflight included. + +A template the router cannot match, such as text around a variable within one +segment (`/v1/a{name}b`), is left out with an error in the log; the other +routes still serve. + ## Client address The proxy works out who the client is once per request, before any guard @@ -1181,7 +1226,8 @@ A plain TCP connection passes `stream.connect_info()` directly ### What passes through A request no route matches gets `404`, or goes to your own service with -`ProxyService::with_fallback(my_axum_app)`. Native gRPC calls and the fallback +`ProxyService::with_fallback(my_axum_app)`; so does a URL whose path a +transcoded route matches but no binding answers. Native gRPC calls and the fallback pass only the guards whose scope names `grpc` or `fallback` (see [Guards and scopes](#guards-and-scopes)); by default none does. The fallback keeps its own CORS and tracing. @@ -1467,3 +1513,5 @@ USDT (TRC-20): `TFDsezHa1cBkoeZT5q2T49Wp66K8t2DmdA` ## License Apache-2.0 + +Contributions are accepted under the [Structured World Contributor License Agreement](https://sw.foundation/cla); see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..875988a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security policy + +structured-proxy faces untrusted clients: a way past a guard (auth, rate +limits, maintenance), a request that reaches the upstream in a form it should +not, a crash, a hang or unbounded memory on any request is a security issue. + +## Reporting a vulnerability + +Report it privately through +[GitHub private vulnerability reporting](https://github.com/structured-world/structured-proxy/security/advisories/new), +not in a public issue. Include the request or configuration that triggers it, +the version or commit, the features enabled, and what you observed. + +You will get an answer within a few days. A confirmed issue is fixed in a new +release, and the advisory is published with credit to you unless you prefer +otherwise. + +## Supported versions + +Fixes go into the latest release only. diff --git a/src/client_address.rs b/src/client_address.rs index 270c22f..fafed73 100644 --- a/src/client_address.rs +++ b/src/client_address.rs @@ -97,9 +97,7 @@ use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::task::{Context, Poll}; -use axum::extract::{ConnectInfo, Request}; -use axum::middleware::Next; -use axum::response::Response; +use axum::extract::ConnectInfo; use http::header::{HeaderName, FORWARDED}; use http::{HeaderMap, HeaderValue}; use ipnet::IpNet; @@ -358,8 +356,9 @@ impl Resolver { self.resolve_noting(peer, headers, None) } - /// [`resolve`](Self::resolve), noting in `hops` the trusted proxies - /// between the client and this proxy, nearest first. + /// The client address of a request from `peer` carrying `headers` (what + /// [`apply`](Self::apply) puts on it), noting in `hops` the trusted + /// proxies between the client and this proxy, nearest first. fn resolve_noting( &self, peer: Option, @@ -748,26 +747,5 @@ where } } -/// The guard of `client_address.required`: a request whose address did not -/// resolve is refused, `INVALID_ARGUMENT` when a trusted proxy forwarded -/// something unreadable, `INTERNAL` when the server recorded no connection. -pub(crate) async fn require(request: Request, next: Next) -> Response { - let resolution = request - .extensions() - .get::() - .map_or(Resolution::Unavailable, ClientAddress::resolution); - match resolution { - Resolution::Peer(_) | Resolution::Forwarded(_) => next.run(request).await, - Resolution::Invalid(invalid) => crate::guard::reject( - tonic::Code::InvalidArgument, - format!("the client address a trusted proxy forwarded is {invalid}"), - ), - Resolution::Unavailable => crate::guard::reject( - tonic::Code::Internal, - "no connection information to resolve the client address from", - ), - } -} - #[cfg(test)] mod tests; diff --git a/src/cors.rs b/src/cors.rs index 533bb0c..f7f9982 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -1,68 +1,248 @@ -//! CORS around the router that answers only real preflight requests. +//! CORS, answering only real preflight requests. //! -//! tower-http's `CorsLayer` answers every `OPTIONS` request as a preflight, so -//! no route could serve `OPTIONS`: not a `custom` rule, not a `*` rule, not the -//! forward-auth endpoint, whose sub-request carries the original request's -//! method and would be allowed through by that `200`. The Fetch standard (§3.2.2, -//! CORS request and CORS-preflight request) makes a preflight an `OPTIONS` -//! request carrying both `Origin` and `Access-Control-Request-Method`; any other -//! `OPTIONS` is an ordinary request. Such a request passes the CORS layer under a -//! stand-in method, so it gets the response headers of an ordinary CORS request, -//! and has its method restored before anything else sees it. - -use axum::extract::Request; -use axum::http::header::{ACCESS_CONTROL_REQUEST_METHOD, ORIGIN}; -use axum::http::Method; -use axum::middleware::{self, Next}; -use axum::response::Response; -use axum::Router; -use tower_http::cors::CorsLayer; - -/// Marks a request whose method the outer layer replaced with [`stand_in`]. -#[derive(Clone, Copy)] -struct OrdinaryOptions; - -/// The method an ordinary `OPTIONS` request carries through the CORS layer -/// (short enough for `http` to store inline, without allocating). A client -/// sending it itself gets no special treatment: without the marker it is left -/// as it is and matches no route. -fn stand_in() -> Method { - Method::from_bytes(b"X-SP-OPTIONS").expect("a valid method token") +//! The Fetch standard (§3.2.2, CORS request and CORS-preflight request) makes +//! a preflight an `OPTIONS` request carrying both `Origin` and +//! `Access-Control-Request-Method`; any other `OPTIONS` is an ordinary request +//! and goes on to the routes like any other, so a `custom` rule, a `*` rule or +//! the forward-auth endpoint (whose sub-request carries the original method) +//! can serve it. +//! +//! The policy is one of two: every origin, as a development default, or the +//! configured origins with credentials. Every header value it can send is +//! built once; a request costs a reference count and the headers it gets. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{ready, Context, Poll}; + +use axum::http::header::{ + ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, + ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, + ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN, VARY, +}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response}; +use pin_project_lite::pin_project; +use tower::{Layer, Service}; + +/// A CORS policy, as a layer. +#[derive(Clone, Debug)] +pub(crate) struct CorsLayer(Arc); + +#[derive(Debug)] +struct Policy { + /// `None` for every origin: `*` for the origin, the methods and the + /// exposed headers, the request headers a preflight asks for named back, + /// and no credentials. Otherwise the + /// origins answered, with credentials, whose preflight gets back the + /// method and headers it asked for (the Fetch standard, §3.2.5, forbids + /// `*` with credentials). + origins: Option>, + /// The response headers a browser may read, for the listed origins. + exposed: Option, + /// How long a browser may cache a preflight's answer. + max_age: Option, +} + +const WILDCARD: HeaderValue = HeaderValue::from_static("*"); +const TRUE: HeaderValue = HeaderValue::from_static("true"); +/// What an answer for the listed origins depends on (RFC 9110 §12.5.5). +const VARY_REQUEST: HeaderValue = HeaderValue::from_static( + "origin, access-control-request-method, access-control-request-headers", +); +/// What a preflight's answer for every origin depends on. +const VARY_REQUEST_HEADERS: HeaderValue = + HeaderValue::from_static("access-control-request-headers"); + +impl CorsLayer { + /// Every origin, every method and header. + pub(crate) fn any(max_age: Option) -> Self { + Self(Arc::new(Policy { + origins: None, + exposed: None, + max_age: max_age.map(HeaderValue::from), + })) + } + + /// `origins`, with credentials, reading `exposed`. + pub(crate) fn listed( + origins: Vec, + exposed: &[HeaderName], + max_age: Option, + ) -> Self { + let exposed = (!exposed.is_empty()).then(|| { + let names: Vec<&str> = exposed.iter().map(HeaderName::as_str).collect(); + HeaderValue::from_str(&names.join(",")).expect("header names are header values") + }); + Self(Arc::new(Policy { + origins: Some(origins), + exposed, + max_age: max_age.map(HeaderValue::from), + })) + } +} + +impl Layer for CorsLayer { + type Service = Cors; + + fn layer(&self, inner: S) -> Cors { + Cors { + policy: self.0.clone(), + inner, + } + } } -/// `router` wrapped in `cors`, with only real preflights answered by it. -pub(crate) fn layer(router: Router, cors: CorsLayer) -> Router +/// The service of a [`CorsLayer`]. +#[derive(Clone, Debug)] +pub(crate) struct Cors { + policy: Arc, + inner: S, +} + +impl Policy { + /// The `Access-Control-Allow-Origin` an `origin` gets: `*`, or the origin + /// itself when it is listed. + fn allow_origin(&self, origin: Option<&HeaderValue>) -> Option { + match &self.origins { + None => Some(WILDCARD), + Some(listed) => origin.filter(|origin| listed.contains(origin)).cloned(), + } + } + + /// The answer to a preflight with `headers`. + fn preflight(&self, headers: &HeaderMap) -> Response { + let mut response = Response::new(B::default()); + let answer = response.headers_mut(); + match &self.origins { + None => { + answer.insert(ACCESS_CONTROL_ALLOW_METHODS, WILDCARD); + // `*` does not cover `Authorization` (Fetch §3.2.3, the + // CORS-preflight fetch): the headers asked for are named back, + // which no credentials make unsafe here. + match headers.get(ACCESS_CONTROL_REQUEST_HEADERS) { + Some(requested) => { + answer.insert(ACCESS_CONTROL_ALLOW_HEADERS, requested.clone()); + answer.insert(VARY, VARY_REQUEST_HEADERS); + } + None => { + answer.insert(ACCESS_CONTROL_ALLOW_HEADERS, WILDCARD); + } + } + } + Some(_) => { + answer.insert(ACCESS_CONTROL_ALLOW_CREDENTIALS, TRUE); + answer.insert(VARY, VARY_REQUEST); + if let Some(method) = headers.get(ACCESS_CONTROL_REQUEST_METHOD) { + answer.insert(ACCESS_CONTROL_ALLOW_METHODS, method.clone()); + } + if let Some(requested) = headers.get(ACCESS_CONTROL_REQUEST_HEADERS) { + answer.insert(ACCESS_CONTROL_ALLOW_HEADERS, requested.clone()); + } + } + } + if let Some(max_age) = &self.max_age { + answer.insert(ACCESS_CONTROL_MAX_AGE, max_age.clone()); + } + if let Some(origin) = self.allow_origin(headers.get(ORIGIN)) { + answer.insert(ACCESS_CONTROL_ALLOW_ORIGIN, origin); + } + response + } + + /// Put the headers of an ordinary CORS request on `headers`, those of + /// the response, over its own: `Vary` alongside the response's. + fn answer(&self, headers: &mut HeaderMap, allow_origin: Option) { + match &self.origins { + None => { + headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, WILDCARD); + headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, WILDCARD); + } + Some(_) => { + headers.append(VARY, VARY_REQUEST); + headers.insert(ACCESS_CONTROL_ALLOW_CREDENTIALS, TRUE); + if let Some(origin) = allow_origin { + headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, origin); + } + if let Some(exposed) = &self.exposed { + headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, exposed.clone()); + } + } + } + } +} + +impl Service> for Cors where - S: Clone + Send + Sync + 'static, + S: Service, Response = Response>, + ResBody: Default, { - router - .layer(middleware::from_fn(restore_options)) - .layer(cors) - .layer(middleware::from_fn(disguise_options)) + type Response = Response; + type Error = S::Error; + type Future = CorsFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let headers = request.headers(); + // A CORS-preflight request (Fetch §3.2.2); any other OPTIONS is an + // ordinary one. + if request.method() == Method::OPTIONS + && headers.contains_key(ORIGIN) + && headers.contains_key(ACCESS_CONTROL_REQUEST_METHOD) + { + return CorsFuture::Preflight { + response: Some(self.policy.preflight(headers)), + }; + } + let allow_origin = self.policy.allow_origin(headers.get(ORIGIN)); + CorsFuture::Call { + future: self.inner.call(request), + policy: Some(self.policy.clone()), + allow_origin, + } + } } -/// Outermost: hide an ordinary `OPTIONS` from the CORS layer. -async fn disguise_options(mut request: Request, next: Next) -> Response { - let headers = request.headers(); - let preflight = - headers.contains_key(ORIGIN) && headers.contains_key(ACCESS_CONTROL_REQUEST_METHOD); - if request.method() == Method::OPTIONS && !preflight { - *request.method_mut() = stand_in(); - request.extensions_mut().insert(OrdinaryOptions); +pin_project! { + /// The response future of [`Cors`]. + #[project = CorsProjection] + pub(crate) enum CorsFuture { + Call { + #[pin] + future: F, + policy: Option>, + allow_origin: Option, + }, + Preflight { response: Option> }, } - next.run(request).await } -/// Right inside the CORS layer: give the request its method back. -async fn restore_options(mut request: Request, next: Next) -> Response { - if request - .extensions_mut() - .remove::() - .is_some() - { - *request.method_mut() = Method::OPTIONS; +impl Future for CorsFuture +where + F: Future, E>>, +{ + type Output = Result, E>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.project() { + CorsProjection::Call { + future, + policy, + allow_origin, + } => { + let mut response = ready!(future.poll(cx))?; + let policy = policy.take().expect("polled after completion"); + policy.answer(response.headers_mut(), allow_origin.take()); + Poll::Ready(Ok(response)) + } + CorsProjection::Preflight { response } => { + Poll::Ready(Ok(response.take().expect("polled after completion"))) + } + } } - next.run(request).await } #[cfg(test)] diff --git a/src/cors/tests.rs b/src/cors/tests.rs index 9bff9ce..f415ba3 100644 --- a/src/cors/tests.rs +++ b/src/cors/tests.rs @@ -1,20 +1,34 @@ use super::*; use axum::body::Body; +use axum::extract::Request; use axum::http::StatusCode; use axum::routing::any; +use axum::Router; use tower::ServiceExt; -/// A router whose only route answers every method with the method it saw. +/// A router whose only route answers every method with the method it saw, a +/// `Vary` of its own on it, behind `cors`. +fn app_with(cors: CorsLayer) -> Router { + Router::new() + .route( + "/x", + any(|method: Method| async move { + ([(VARY, "accept-encoding")], method.as_str().to_owned()) + }), + ) + .layer(cors) +} + fn app() -> Router { - let router = Router::new().route( - "/x", - any(|method: Method| async move { method.as_str().to_owned() }), - ); - layer(router, CorsLayer::permissive()) + app_with(CorsLayer::any(None)) } async fn send(request: Request) -> (StatusCode, axum::http::HeaderMap, String) { - let response = app().oneshot(request).await.unwrap(); + send_to(app(), request).await +} + +async fn send_to(app: Router, request: Request) -> (StatusCode, axum::http::HeaderMap, String) { + let response = app.oneshot(request).await.unwrap(); let (parts, body) = response.into_parts(); let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); ( @@ -86,13 +100,112 @@ async fn other_methods_are_untouched() { } #[tokio::test] -async fn stand_in_method_sent_by_a_client_is_not_turned_into_options() { - let request = Request::builder() - .method(stand_in()) - .uri("/x") +async fn every_origin_gets_wildcards_and_no_credentials() { + let (_, headers, _) = send(options(&[ + ("origin", "https://a.example"), + ("access-control-request-method", "PUT"), + ])) + .await; + assert_eq!(headers["access-control-allow-origin"], "*"); + assert_eq!(headers["access-control-allow-methods"], "*"); + assert_eq!(headers["access-control-allow-headers"], "*"); + assert!(!headers.contains_key("access-control-allow-credentials")); + assert!(!headers.contains_key("vary")); + let request = Request::get("/x") + .header("origin", "https://a.example") .body(Body::empty()) .unwrap(); - let (status, _, body) = send(request).await; + let (_, headers, _) = send(request).await; + assert_eq!(headers["access-control-expose-headers"], "*"); + let vary: Vec<_> = headers.get_all("vary").iter().collect(); + assert_eq!(vary, ["accept-encoding"]); +} + +#[tokio::test] +async fn every_origin_may_send_the_headers_it_asks_for_authorization_included() { + // `*` in Access-Control-Allow-Headers does not cover `Authorization` + // (Fetch §3.2.3): the headers a preflight asks for are named back, and + // the answer varies with them. + let (_, headers, _) = send(options(&[ + ("origin", "https://a.example"), + ("access-control-request-method", "GET"), + ("access-control-request-headers", "authorization,x-a"), + ])) + .await; + assert_eq!(headers["access-control-allow-headers"], "authorization,x-a"); + assert_eq!(headers["vary"], "access-control-request-headers"); + assert_eq!(headers["access-control-allow-origin"], "*"); +} + +/// The configured origins with credentials, exposing two headers, a preflight +/// cached for ten minutes. +fn listed() -> Router { + app_with(CorsLayer::listed( + vec![HeaderValue::from_static("https://a.example")], + &[ + HeaderName::from_static("grpc-status"), + HeaderName::from_static("x-custom"), + ], + Some(600), + )) +} + +#[tokio::test] +async fn a_listed_origin_preflight_gets_back_what_it_asked_for() { + // With credentials the Fetch standard (§3.2.5) forbids `*`: the method + // and headers asked for are echoed, for the listed origin only. + let request = options(&[ + ("origin", "https://a.example"), + ("access-control-request-method", "PUT"), + ("access-control-request-headers", "x-a,x-b"), + ]); + let (status, headers, body) = send_to(listed(), request).await; assert_eq!(status, StatusCode::OK); - assert_eq!(body, "X-SP-OPTIONS"); + assert!(body.is_empty(), "{body}"); + assert_eq!(headers["access-control-allow-origin"], "https://a.example"); + assert_eq!(headers["access-control-allow-methods"], "PUT"); + assert_eq!(headers["access-control-allow-headers"], "x-a,x-b"); + assert_eq!(headers["access-control-allow-credentials"], "true"); + assert_eq!(headers["access-control-max-age"], "600"); + assert_eq!( + headers["vary"], + "origin, access-control-request-method, access-control-request-headers" + ); +} + +#[tokio::test] +async fn a_listed_origin_reads_the_exposed_headers() { + let request = Request::get("/x") + .header("origin", "https://a.example") + .body(Body::empty()) + .unwrap(); + let (_, headers, body) = send_to(listed(), request).await; + assert_eq!(body, "GET"); + assert_eq!(headers["access-control-allow-origin"], "https://a.example"); + assert_eq!(headers["access-control-allow-credentials"], "true"); + assert_eq!( + headers["access-control-expose-headers"], + "grpc-status,x-custom" + ); + // The response's own `Vary` stays beside the CORS one. + let vary: Vec<_> = headers.get_all("vary").iter().collect(); + assert_eq!( + vary, + [ + "accept-encoding", + "origin, access-control-request-method, access-control-request-headers" + ] + ); + assert!(!headers.contains_key("access-control-max-age")); +} + +#[tokio::test] +async fn an_origin_not_listed_is_not_allowed() { + let request = Request::get("/x") + .header("origin", "https://b.example") + .body(Body::empty()) + .unwrap(); + let (status, headers, body) = send_to(listed(), request).await; + assert_eq!((status, body.as_str()), (StatusCode::OK, "GET")); + assert!(!headers.contains_key("access-control-allow-origin")); } diff --git a/src/embed.rs b/src/embed.rs index b18f317..2af5524 100644 --- a/src/embed.rs +++ b/src/embed.rs @@ -213,6 +213,12 @@ where ) } +/// Whether `route` is mounted: its method is one the router's method filter +/// holds. Another one is skipped, with a warning, and reserves no path. +pub(crate) fn mountable(route: &ExtraRoute) -> bool { + MethodFilter::try_from(route.method.clone()).is_ok() +} + /// Build a router for the embedder's extra stateless routes. /// /// Routes that share a path but differ in method are merged into one diff --git a/src/guard.rs b/src/guard.rs index 15be9bf..e4c1644 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -6,6 +6,7 @@ //! status ([`GrpcRejections`]). mod concurrency; +mod gate; mod grpc; use std::borrow::Cow; @@ -13,8 +14,8 @@ use std::convert::Infallible; use std::sync::Arc; use std::task::{Context, Poll}; -use axum::extract::{Request, State}; -use axum::middleware::{from_fn_with_state, Next}; +use axum::extract::Request; +use axum::middleware::from_fn_with_state; use axum::response::{IntoResponse, Response}; use axum::{Json, Router}; use futures::future::Either; @@ -140,7 +141,7 @@ pub(crate) struct Scope { impl Scope { /// Compile `config` for the guard named `what`, with `default` traffic /// when the config names none; `routed` are the methods the proxy's - /// routes answer beyond the standard ones (`custom` rules, extra routes). + /// routes answer beyond the standard ones. /// /// # Errors /// A scope that covers no traffic, a path glob that is relative or does @@ -150,7 +151,7 @@ impl Scope { config: Option<&ScopeConfig>, default: &[Traffic], what: &str, - routed: &[Method], + routed: Routed<'_>, ) -> Result, String> { let traffic = config.and_then(|c| c.traffic.as_deref()).unwrap_or(default); let classes = traffic.iter().fold(0, |acc, t| acc | traffic_bits(*t)); @@ -188,7 +189,7 @@ impl Scope { Some( names .iter() - .map(|name| scope_method(name, what, classes & FALLBACK != 0, routed)) + .map(|name| scope_method(name, what, classes, routed)) .collect::, _>>()?, ) }; @@ -232,16 +233,35 @@ const STANDARD_METHODS: [Method; 9] = [ Method::PATCH, ]; -/// One `scope.methods` entry of the guard `what`: a method some request can -/// carry, or an error. A method no route answers would match no request and -/// leave the guard covering nothing, unless the scope covers the fallback, -/// whose methods are the embedder's. -fn scope_method( - name: &str, - what: &str, - covers_fallback: bool, - routed: &[Method], -) -> Result { +/// The methods the proxy's routes answer beyond the standard ones. +#[derive(Clone, Copy)] +pub(crate) struct Routed<'a> { + /// The methods routes name: `custom` rules, extra routes. + methods: &'a [Method], + /// The classes with a route answering every method. + every: u8, +} + +impl<'a> Routed<'a> { + /// Routes that answer `methods`. + pub(crate) fn new(methods: &'a [Method]) -> Self { + Self { methods, every: 0 } + } + + /// With a route of `class` that answers every method: a `custom` `*` + /// rule, the verify endpoint. It answers only a scope covering its class. + #[must_use] + pub(crate) fn every(mut self, class: Class) -> Self { + self.every |= class.bit(); + self + } +} + +/// One `scope.methods` entry of the guard `what`, whose scope covers +/// `classes`: a method some request can carry, or an error. A method no route +/// answers would match no request and leave the guard covering nothing, +/// unless the scope covers the fallback, whose methods are the embedder's. +fn scope_method(name: &str, what: &str, classes: u8, routed: Routed<'_>) -> Result { if name == "*" { return Err(format!( "{what}.scope.methods entry \"*\" is not a method: leave methods out to cover every one" @@ -249,7 +269,10 @@ fn scope_method( } let method = Method::from_bytes(name.to_ascii_uppercase().as_bytes()) .map_err(|_| format!("{what}.scope.methods entry {name:?} is not a method"))?; - if covers_fallback || STANDARD_METHODS.contains(&method) || routed.contains(&method) { + if classes & (FALLBACK | routed.every) != 0 + || STANDARD_METHODS.contains(&method) + || routed.methods.contains(&method) + { Ok(method) } else { Err(format!( @@ -363,7 +386,11 @@ macro_rules! guard_stack { } if class.authenticates() { // Keyed by claims only the JWT gate verifies. - if let Some((shield, scope)) = &guards.shield { + if let Some((shield, scope)) = guards + .shield + .as_ref() + .filter(|(shield, _)| shield.enforces(crate::shield::matcher::Phase::PostAuth)) + { target = $wrap!( target, class, @@ -380,44 +407,21 @@ macro_rules! guard_stack { ); } } - if let Some((shield, scope)) = &guards.shield { - target = $wrap!( - target, - class, - scope, - from_fn_with_state(shield.clone(), crate::shield::pre_auth_middleware) - ); - } - if let Some((concurrency, scope)) = &guards.concurrency { - target = $wrap!( - target, - class, - scope, - from_fn_with_state(concurrency.clone(), concurrency::middleware) - ); - } - if let Some((maintenance, scope)) = &guards.maintenance { - target = $wrap!( - target, - class, - scope, - from_fn_with_state(maintenance.clone(), maintenance_middleware) - ); - } - if let Some(scope) = &guards.require_client { - target = $wrap!( - target, - class, - scope, - axum::middleware::from_fn(crate::client_address::require) - ); + // A required client address, maintenance, concurrency and the rate + // limits before auth decide as the request arrives: one layer. + if let Some(gate) = gate::Gate::layer(guards, class) { + target = $wrap!(@all target, gate); } target }}; } -/// One guard layer on a router, when its scope covers the class. +/// One guard layer on a router, when its scope covers the class; with `@all`, +/// a layer that applies its scopes itself. macro_rules! wrap_router { + (@all $target:expr, $layer:expr) => { + $target.layer($layer) + }; ($target:expr, $class:expr, $scope:expr, $layer:expr) => {{ let scope: &Arc = $scope; if !scope.covers($class) { @@ -433,8 +437,12 @@ macro_rules! wrap_router { }}; } -/// One guard layer on a boxed service, when its scope covers the class. +/// One guard layer on a boxed service, when its scope covers the class; with +/// `@all`, a layer that applies its scopes itself. macro_rules! wrap_service { + (@all $target:expr, $layer:expr) => { + BoxedService::new($layer.layer($target)) + }; ($target:expr, $class:expr, $scope:expr, $layer:expr) => {{ let scope: &Arc = $scope; let target: BoxedService = $target; @@ -507,23 +515,5 @@ impl Maintenance { } } -/// Maintenance mode middleware: `UNAVAILABLE` with a five-minute -/// `Retry-After` for every request outside the exempt paths. -async fn maintenance_middleware( - State(maintenance): State>, - request: Request, - next: Next, -) -> Response { - if maintenance.exempts(request.uri().path()) { - return next.run(request).await; - } - let mut response = reject(tonic::Code::Unavailable, maintenance.message.clone()); - response.headers_mut().insert( - http::header::RETRY_AFTER, - http::HeaderValue::from_static("300"), - ); - response -} - #[cfg(test)] mod tests; diff --git a/src/guard/concurrency.rs b/src/guard/concurrency.rs index fde5ce4..f87bd99 100644 --- a/src/guard/concurrency.rs +++ b/src/guard/concurrency.rs @@ -4,10 +4,8 @@ use std::sync::Arc; -use axum::extract::{Request, State}; -use axum::middleware::Next; use axum::response::Response; -use tokio::sync::Semaphore; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use super::reject; use crate::config::ConcurrencyConfig; @@ -37,23 +35,21 @@ impl Concurrency { } } -/// Take a slot for the request and hold it until its response body ends, so -/// a stream counts for its whole life; with none free, `UNAVAILABLE` and a -/// one-second `Retry-After`. -pub(super) async fn middleware( - State(concurrency): State>, - request: Request, - next: Next, -) -> Response { - let Ok(slot) = concurrency.slots.clone().try_acquire_owned() else { +impl Concurrency { + /// A slot for a request, held until its response body ends, so a stream + /// counts for its whole life; `None` when none is free. + pub(super) fn take(&self) -> Option { + self.slots.clone().try_acquire_owned().ok() + } + + /// The answer to a request no slot is free for: `UNAVAILABLE` and a + /// one-second `Retry-After`. + pub(super) fn full() -> Response { let mut response = reject(tonic::Code::Unavailable, "too many requests in flight"); response.headers_mut().insert( http::header::RETRY_AFTER, http::HeaderValue::from_static("1"), ); - return response; - }; - next.run(request) - .await - .map(|body| crate::held::until_end(body, slot)) + response + } } diff --git a/src/guard/gate.rs b/src/guard/gate.rs new file mode 100644 index 0000000..943a966 --- /dev/null +++ b/src/guard/gate.rs @@ -0,0 +1,231 @@ +//! The guards decided as the request arrives, in pipeline order: a required +//! client address, maintenance mode, the concurrency limit and the rate limits +//! keyed before auth. One layer runs them all, with no boxed future and no +//! clone of the service behind it. + +use std::convert::Infallible; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::Request; +use axum::response::Response; +use http::header::RETRY_AFTER; +use http::HeaderValue; +use pin_project_lite::pin_project; +use tokio::sync::OwnedSemaphorePermit; +use tower::{Layer, Service}; + +use super::{reject, Class, Concurrency, Guards, Maintenance, Scope}; +use crate::client_address::{ClientAddress, Resolution}; +use crate::shield::matcher::Phase; +use crate::shield::{Decision, Report, Shield}; + +/// The guards of one class of traffic decided as the request arrives, each +/// with its scope. +pub(crate) struct Gate { + require_client: Option>, + maintenance: Option<(Arc, Arc)>, + concurrency: Option<(Arc, Arc)>, + shield: Option<(Arc, Arc)>, +} + +impl Gate { + /// The layer of the guards of `guards` that cover `class`, or `None` + /// when none does. + pub(super) fn layer(guards: &Guards, class: Class) -> Option { + let covered = |scope: &Arc| scope.covers(class); + let gate = Self { + require_client: guards.require_client.clone().filter(covered), + maintenance: guards + .maintenance + .clone() + .filter(|(_, scope)| covered(scope)), + concurrency: guards + .concurrency + .clone() + .filter(|(_, scope)| covered(scope)), + shield: guards + .shield + .clone() + .filter(|(shield, scope)| covered(scope) && shield.enforces(Phase::PreAuth)), + }; + let empty = gate.require_client.is_none() + && gate.maintenance.is_none() + && gate.concurrency.is_none() + && gate.shield.is_none(); + (!empty).then(|| GateLayer(Arc::new(gate))) + } + + /// Run the guards on `request`: the answer of the first that turns it + /// away, or what the response owes the ones that let it through. + fn admit(&self, request: &Request) -> Admission { + if let Some(scope) = &self.require_client { + if applies(scope, request) { + if let Some(refusal) = unresolved_client(request) { + return Admission::Refused(refusal); + } + } + } + if let Some((maintenance, scope)) = &self.maintenance { + if applies(scope, request) && !maintenance.exempts(request.uri().path()) { + let mut response = reject(tonic::Code::Unavailable, maintenance.message.clone()); + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static("300")); + return Admission::Refused(response); + } + } + let mut admitted = Admitted::default(); + if let Some((concurrency, scope)) = &self.concurrency { + if applies(scope, request) { + match concurrency.take() { + Some(slot) => admitted.slot = Some(slot), + None => return Admission::Refused(Concurrency::full()), + } + } + } + if let Some((shield, scope)) = &self.shield { + if applies(scope, request) { + match shield.decide(Phase::PreAuth, request) { + Decision::Pass => {} + // Turned away here, the request is not in flight: its + // slot is free again. + Decision::Reject(response) => return Admission::Refused(response), + Decision::Report(report) => admitted.report = Some(report), + } + } + } + Admission::Admitted(admitted) + } +} + +/// What the guards make of a request. +enum Admission { + Admitted(Admitted), + Refused(Response), +} + +/// Whether a guard with `scope`, which covers the request's class, applies +/// to it. +fn applies(scope: &Scope, request: &Request) -> bool { + !scope.narrows() || scope.matches(request) +} + +/// `client_address.required`: a request whose address did not resolve is +/// refused, `INVALID_ARGUMENT` when a trusted proxy forwarded something +/// unreadable, `INTERNAL` when the server recorded no connection. +fn unresolved_client(request: &Request) -> Option { + let resolution = request + .extensions() + .get::() + .map_or(Resolution::Unavailable, ClientAddress::resolution); + match resolution { + Resolution::Peer(_) | Resolution::Forwarded(_) => None, + Resolution::Invalid(invalid) => Some(reject( + tonic::Code::InvalidArgument, + format!("the client address a trusted proxy forwarded is {invalid}"), + )), + Resolution::Unavailable => Some(reject( + tonic::Code::Internal, + "no connection information to resolve the client address from", + )), + } +} + +/// What the response of an admitted request owes the guards: the in-flight +/// slot it holds until its body ends, the rate-limit budget it reports. +#[derive(Default)] +struct Admitted { + slot: Option, + report: Option, +} + +impl Admitted { + fn settle(self, mut response: Response) -> Response { + if let Some(report) = &self.report { + report.apply(&mut response); + } + match self.slot { + Some(slot) => response.map(|body| crate::held::until_end(body, slot)), + None => response, + } + } +} + +/// The layer of a [`Gate`]. +#[derive(Clone)] +pub(crate) struct GateLayer(Arc); + +impl Layer for GateLayer { + type Service = GateService; + + fn layer(&self, inner: S) -> GateService { + GateService { + gate: self.0.clone(), + inner, + } + } +} + +/// The service of a [`GateLayer`]. +#[derive(Clone)] +pub(crate) struct GateService { + gate: Arc, + inner: S, +} + +impl Service for GateService +where + S: Service, +{ + type Response = Response; + type Error = Infallible; + type Future = GateFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + match self.gate.admit(&request) { + Admission::Admitted(admitted) => GateFuture::Admitted { + future: self.inner.call(request), + admitted: Some(admitted), + }, + Admission::Refused(response) => GateFuture::Refused { + response: Some(response), + }, + } + } +} + +pin_project! { + /// The response future of a [`GateService`]. + #[project = GateProjection] + pub(crate) enum GateFuture { + Admitted { #[pin] future: F, admitted: Option }, + Refused { response: Option }, + } +} + +impl Future for GateFuture +where + F: Future>, +{ + type Output = Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match self.project() { + GateProjection::Admitted { future, admitted } => { + let Ok(response) = std::task::ready!(future.poll(cx)); + let admitted = admitted.take().expect("polled after completion"); + Poll::Ready(Ok(admitted.settle(response))) + } + GateProjection::Refused { response } => { + Poll::Ready(Ok(response.take().expect("polled after completion"))) + } + } + } +} diff --git a/src/guard/tests.rs b/src/guard/tests.rs index 6b96bf3..ffd5603 100644 --- a/src/guard/tests.rs +++ b/src/guard/tests.rs @@ -1,6 +1,12 @@ use super::*; use crate::upstream::GrpcProtocol; +/// No route answering a method beyond the standard ones. +const STANDARD: Routed<'static> = Routed { + methods: &[], + every: 0, +}; + fn maintenance() -> Maintenance { Maintenance { exempt: vec![ @@ -45,7 +51,13 @@ fn request(method: &str, path: &str) -> http::Request<()> { #[test] fn a_scope_without_traffic_takes_the_default() { - let scope = Scope::compile(None, &[Traffic::Transcoded, Traffic::Grpc], "shield", &[]).unwrap(); + let scope = Scope::compile( + None, + &[Traffic::Transcoded, Traffic::Grpc], + "shield", + STANDARD, + ) + .unwrap(); assert!(scope.covers(Class::Transcoded)); assert!(scope.covers(Class::Grpc)); assert!(!scope.covers(Class::Endpoints)); @@ -56,7 +68,7 @@ fn a_scope_without_traffic_takes_the_default() { #[test] fn all_traffic_covers_every_class() { let config = ScopeConfig::traffic([Traffic::All]); - let scope = Scope::compile(Some(&config), &[], "shield", &[]).unwrap(); + let scope = Scope::compile(Some(&config), &[], "shield", STANDARD).unwrap(); for class in [ Class::Transcoded, Class::Endpoints, @@ -72,7 +84,7 @@ fn all_traffic_covers_every_class() { fn a_scope_that_covers_no_traffic_is_an_error() { // An empty list would mount the guard nowhere; a typo, not a choice. let config = ScopeConfig::traffic([]); - let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "auth", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "auth", STANDARD).unwrap_err(); assert!(err.contains("auth.scope.traffic"), "{err}"); } @@ -82,14 +94,16 @@ fn a_relative_or_invalid_path_glob_is_an_error() { paths: vec!["v1/**".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&relative), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&relative), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("must start with '/'"), "{err}"); let invalid = ScopeConfig { paths: vec!["/v1/[".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&invalid), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&invalid), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("is invalid"), "{err}"); } @@ -99,7 +113,8 @@ fn an_invalid_method_is_an_error() { methods: vec!["GE T".into()], ..ScopeConfig::default() }; - let err = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", &[]).unwrap_err(); + let err = + Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", STANDARD).unwrap_err(); assert!(err.contains("is not a method"), "{err}"); } @@ -115,7 +130,7 @@ fn a_catch_all_method_is_an_error() { // `*` means every method in route policies; here it would match no // request and leave the guard covering nothing. let config = methods(&["*"], Traffic::Transcoded); - let err = Scope::compile(Some(&config), &[], "auth", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[], "auth", STANDARD).unwrap_err(); assert!(err.contains("leave methods out"), "{err}"); } @@ -123,7 +138,7 @@ fn a_catch_all_method_is_an_error() { fn a_method_no_route_answers_is_an_error() { // A typo matches no request, and the guard would silently cover nothing. let config = methods(&["PSOT"], Traffic::Transcoded); - let err = Scope::compile(Some(&config), &[], "shield", &[]).unwrap_err(); + let err = Scope::compile(Some(&config), &[], "shield", STANDARD).unwrap_err(); assert!(err.contains("\"PSOT\""), "{err}"); } @@ -131,8 +146,16 @@ fn a_method_no_route_answers_is_an_error() { fn an_extension_method_a_route_answers_is_accepted() { let config = methods(&["PROPFIND"], Traffic::Transcoded); let routed = [Method::from_bytes(b"PROPFIND").unwrap()]; - let scope = Scope::compile(Some(&config), &[], "shield", &routed).unwrap(); + let scope = Scope::compile(Some(&config), &[], "shield", Routed::new(&routed)).unwrap(); + assert!(scope.matches(&request("PROPFIND", "/dav"))); + // A transcoded route answering every method answers it too, but only + // for a scope over transcoded traffic. + let every = STANDARD.every(Class::Transcoded); + let scope = Scope::compile(Some(&config), &[], "shield", every).unwrap(); assert!(scope.matches(&request("PROPFIND", "/dav"))); + let endpoints = methods(&["PROPFIND"], Traffic::Endpoints); + let err = Scope::compile(Some(&endpoints), &[], "shield", every).unwrap_err(); + assert!(err.contains("\"PROPFIND\""), "{err}"); } #[test] @@ -140,7 +163,7 @@ fn any_method_token_is_accepted_for_the_fallback() { // The fallback is the embedder's own service: which methods it answers // is not the proxy's to know. let config = methods(&["PROPFIND"], Traffic::Fallback); - assert!(Scope::compile(Some(&config), &[], "shield", &[]).is_ok()); + assert!(Scope::compile(Some(&config), &[], "shield", STANDARD).is_ok()); } #[test] @@ -150,7 +173,7 @@ fn paths_and_methods_narrow_the_requests_a_scope_matches() { methods: vec!["post".into()], ..ScopeConfig::default() }; - let scope = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", &[]).unwrap(); + let scope = Scope::compile(Some(&config), &[Traffic::Transcoded], "shield", STANDARD).unwrap(); assert!(scope.narrows()); assert!(scope.matches(&request("POST", "/v1/orders/42"))); // Methods compare as tokens, case aside. @@ -179,7 +202,7 @@ async fn a_narrowed_guard_readies_the_branch_it_calls() { }; let scoped = Scoped { layer: tower::layer::layer_fn(|inner| inner), - scope: Scope::compile(Some(&config), &[], "maintenance", &[]).unwrap(), + scope: Scope::compile(Some(&config), &[], "maintenance", STANDARD).unwrap(), } .layer(fallback); for path in ["/other", "/guarded", "/other"] { @@ -260,7 +283,25 @@ async fn the_concurrency_limit_holds_a_slot_until_the_response_body_ends() { }), ) .route("/x", axum::routing::get(|| async { "x" })) - .layer(from_fn_with_state(concurrency, concurrency::middleware)); + .layer( + gate::Gate::layer( + &Guards { + concurrency: Some(( + concurrency, + Scope::compile( + None, + &[Traffic::Transcoded], + "concurrency", + Routed::new(&[]), + ) + .unwrap(), + )), + ..Guards::default() + }, + Class::Transcoded, + ) + .unwrap(), + ); let get = |path: &str| { http::Request::get(path) .body(axum::body::Body::empty()) diff --git a/src/lib.rs b/src/lib.rs index 1c8c351..e5b4eea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,9 +90,9 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::get; use axum::{Json, Router}; +use cors::CorsLayer; use prost_reflect::DescriptorPool; use std::net::SocketAddr; -use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; use std::sync::Arc; @@ -109,10 +109,29 @@ pub(crate) struct ProxyState { pub(crate) upstream: U, /// Headers to forward from HTTP to gRPC. pub(crate) forwarded_headers: Arc<[String]>, + /// The same, as header names. + pub(crate) forwarded_names: Arc<[http::HeaderName]>, /// SSE keep-alive interval (seconds) for server-streaming responses. pub(crate) sse_keep_alive_secs: u64, } +/// What [`ProxyServer::routes`] builds. +struct Built { + /// Every route behind its layers, for a router. + router: Router, + /// The choice among the transcoded bindings, made outside the router's + /// layers; `None` without transcoded routes. + chooser: Option, + /// The transcoded routes served past any router, and the other routes; + /// `None` without transcoded routes, or when an other route's path is + /// one they cannot be ranked among. + direct: Option<(transcode::Direct, Router)>, + /// The CORS policy the routes answer under. + cors: CorsLayer, + /// The guards, for the traffic outside the routes. + guards: Arc, +} + /// Universal proxy server. pub struct ProxyServer { config: ProxyConfig, @@ -577,21 +596,19 @@ impl ProxyServer { }) } - /// Every `(method, path)` route mounted before the verify endpoint, used to - /// reject a real collision with a clear error instead of an axum - /// duplicate-route panic. `method` is the uppercase HTTP token; same-path - /// routes with different methods do NOT collide (the extra-route adapter and - /// axum merge them), so the key is the pair, not the path alone. + /// Every `(method, path)` route of the proxy's own endpoints and the + /// embedder's extra routes, the verify endpoint aside. With the + /// transcoded routes, it checks the mounted edge for a real collision, + /// reported as a clear error instead of an axum duplicate-route panic, + /// and ranks the transcoded paths among the others for the proxy + /// service. `method` is the uppercase HTTP token; same-path routes with + /// different methods do NOT collide (the extra-route adapter and axum + /// merge them), so the key is the pair, not the path alone. /// /// Must stay exhaustive: health probes, metrics, OpenAPI spec/docs, the OIDC - /// surface (injected backend or config-driven static discovery), embedder - /// extra routes, and the transcoded REST routes. All built-in surfaces here - /// are `GET`. - fn reserved_routes( - &self, - pool: &DescriptorPool, - selection: &transcode::RpcSelection, - ) -> anyhow::Result> { + /// surface (injected backend or config-driven static discovery) and + /// embedder extra routes. All built-in surfaces here are `GET`. + fn endpoint_routes(&self) -> anyhow::Result> { let mut routes = Vec::new(); let mut get = |path: String| routes.push(("GET".to_string(), path)); if self.config.health.enabled { @@ -623,14 +640,14 @@ impl ProxyServer { } } } - for route in &self.extra_routes { + // Only the extra routes mounted: one the router skips answers nothing. + for route in self + .extra_routes + .iter() + .filter(|route| embed::mountable(route)) + { routes.push((route.method.as_str().to_string(), route.path.clone())); } - routes.extend(transcode::route_paths( - pool, - &self.config.aliases, - selection, - )); Ok(routes) } @@ -659,13 +676,28 @@ impl ProxyServer { /// [`service`](Self::service) for native gRPC on the same listener, or for /// an upstream in process. /// + /// A URL whose path a transcoded route matches but which no binding + /// answers (its field template or custom verb does not fit) goes to the + /// proxy's other routes, extra routes included, and is answered `404` + /// when none of them answers it. An axum router routes a request to one + /// route, so routes merged after this one are not tried for it: serve the + /// application's own routes through + /// [`with_extra_routes`](Self::with_extra_routes), or use + /// [`service`](Self::service) with + /// [`with_fallback`](ProxyService::with_fallback). + /// /// # Errors /// /// No valid upstream address, or a configuration [`service`](Self::service) /// rejects. pub fn router(&self) -> anyhow::Result { - let (router, _, _) = self.routes(self.upstream()?)?; - Ok(router) + let Built { + router, chooser, .. + } = self.routes(self.upstream()?)?; + Ok(match chooser { + Some(chooser) => chooser.layer(router), + None => router, + }) } /// The whole proxy as one tower service in front of `upstream`: native @@ -698,25 +730,33 @@ impl ProxyServer { /// # build().unwrap(); /// ``` pub fn service(&self, upstream: U) -> anyhow::Result> { - let (routes, cors, guards) = self.routes(upstream.clone())?; + let Built { + router, + chooser, + direct, + cors, + guards, + } = self.routes(upstream.clone())?; // The routes answer a browser's preflight for gRPC-Web too, so its // call carries the same policy unless the upstream sets its own. let grpc_web_cors = self.config.cors.grpc_web.then_some(cors); + let routing = match direct { + Some((direct, others)) => service::Routing::Direct { direct, others }, + None => service::Routing::routed(router, chooser), + }; Ok(ProxyService::new( upstream, - routes, + routing, grpc_web_cors, guards, self.config.grpc_web.translate, )) } - /// Build the axum router with all endpoints, calling `upstream`, the CORS - /// policy it answers under, and the guards, for the traffic outside it. - fn routes( - &self, - upstream: U, - ) -> anyhow::Result<(Router, CorsLayer, Arc)> { + /// Build the axum router with all endpoints, calling `upstream`; the + /// choice among its transcoded bindings, made before its layers; the CORS + /// policy it answers under; and the guards, for the traffic outside it. + fn routes(&self, upstream: U) -> anyhow::Result { // Enforce cross-field invariants on the embedded path too, where the // config is built directly instead of through `from_yaml_str`. self.config.validate()?; @@ -737,7 +777,13 @@ impl ProxyServer { // routes with different methods are legal (they merge), so only a // repeated (method, path) — or any overlap with the verify endpoint, // which answers ALL methods (`*`) — is a real conflict. - let mut mounted = self.reserved_routes(&pool, &selection)?; + let endpoints_mounted = self.endpoint_routes()?; + let mut mounted = endpoints_mounted.clone(); + mounted.extend(transcode::route_paths( + &pool, + &self.config.aliases, + &selection, + )); if let Some(vp) = &verify_path { mounted.push(("*".to_string(), vp.clone())); } @@ -767,6 +813,19 @@ impl ProxyServer { } methods.insert(method.as_str()); } + // Paths the router cannot hold together are refused here, with the + // router axum routes with: a variable and a catch-all at one position, + // or one shape spelled with other variable names (routes of other + // methods merge into one path only under the same spelling). + let mut router = matchit::Router::new(); + let mut paths = std::collections::HashSet::new(); + for (_, path) in &mounted { + if paths.insert(path.as_str()) { + if let Err(error) = router.insert(path.as_str(), ()) { + anyhow::bail!("route path {path:?} conflicts with another route: {error}"); + } + } + } // Keep the actually-configured probe / metrics / verify paths reachable // under maintenance mode. The default exempt list names the default @@ -816,16 +875,23 @@ impl ProxyServer { .cloned() .chain(resolver.forwarded_headers().map(str::to_owned)) .collect(); + // Parsed once rather than for every request; every entry is a gRPC + // metadata key, so a header name. + let forwarded_names = forwarded_headers + .iter() + .filter_map(|name| http::HeaderName::from_bytes(name.as_bytes()).ok()) + .collect(); let state = ProxyState { upstream, forwarded_headers, + forwarded_names, sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs, }; let cors = self.build_cors()?; // Build transcoding routes from descriptor pool. - let transcode_routes = transcode::routes_with_options( + let (transcode_routes, choices) = transcode::routes_and_choices( &pool, &self.config.aliases, &self.transcode.clone().with_selection(selection.clone()), @@ -841,7 +907,24 @@ impl ProxyServer { let forward_auth = auth.as_ref().and_then(|built| { auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone()) }); - let guards = Arc::new(self.guards(resolver, auth, maintenance_exempt, &mounted)?); + // The methods a route answers: `*` is not a method. It marks the + // verify endpoint and a `custom` `*` rule, which answer every method, + // and a transcoded path with verbs, whose bindings still name theirs. + let mut methods: Vec = mounted + .iter() + .filter(|(method, _)| method != "*") + .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok()) + .collect(); + let bound = transcode::bound_methods(&pool, &self.config.aliases, &selection); + methods.extend(bound.methods); + let mut routed = guard::Routed::new(&methods); + if bound.every { + routed = routed.every(guard::Class::Transcoded); + } + if verify_path.is_some() { + routed = routed.every(guard::Class::Verify); + } + let guards = Arc::new(self.guards(resolver, auth, maintenance_exempt, routed)?); // Health routes. Paths are configurable; the whole group is skippable. let health_routes = if self.config.health.enabled { @@ -965,26 +1048,89 @@ impl ProxyServer { // Each class of traffic behind the guards that cover it, so a request // runs only the guards of its own class. A path no route answers // reaches the plain 404 (or the fallback's own guards). - let router = Router::new() - .merge(guards.router(transcode_routes, guard::Class::Transcoded)) - .merge(guards.router(endpoints, guard::Class::Endpoints)) - .merge(guards.router(verify, guard::Class::Verify)) - // Before every guard: they and the handlers read its result. - .layer(client_address::ClientAddressLayer::with( - guards.client_address.clone(), + let endpoints = guards.router(endpoints, guard::Class::Endpoints); + let verify = guards.router(verify, guard::Class::Verify); + // One stack, outermost first, applied at once: each layer applied on + // its own would box every route again, and the router clones a + // route's boxes for every request. + let layered = |router: Router>| { + router.layer(( + // Every request, preflights included. + TraceLayer::new_for_http(), + // Wraps every enforcement layer so short-circuited responses + // keep CORS headers, and answers preflight before auth. + cors.clone(), + // Before every guard: they and the handlers read its result. + client_address::ClientAddressLayer::with(guards.client_address.clone()), )) - .layer(TraceLayer::new_for_http()); - // Outermost: wraps every enforcement layer so short-circuited - // responses keep CORS headers, and answers preflight before auth. - let router = cors::layer(router, cors.clone()).with_state(state); - - Ok((router, cors, guards)) + }; + let router = layered( + Router::new() + .merge(guards.router(transcode_routes, guard::Class::Transcoded)) + .merge(endpoints.clone()) + .merge(verify.clone()), + ); + // The binding is chosen before every layer: a URL no binding answers + // (its field template, its custom verb) is no transcoded request. It + // goes to the proxy's other routes, which a router without the + // transcoded ones ranks for it, and on to the fallback (or the plain + // 404) when none answers it either, past the layers like any path no + // route answers. + let elsewhere = + layered(Router::new().merge(endpoints).merge(verify)).with_state(state.clone()); + // The transcoded paths are ranked among the paths of every other + // route: a binding refused at a better-ranked path passes the request + // on only to a transcoded path above them all. + let mut others: Vec<(Option, String)> = endpoints_mounted + .iter() + .map(|(method, path)| { + ( + http::Method::from_bytes(method.as_bytes()).ok(), + path.clone(), + ) + }) + .collect(); + if let Some(path) = &verify_path { + others.push((None, path.clone())); + } + let choices = choices.map(|choices| choices.among(&others)); + // Served by the proxy service, the transcoded routes are matched by + // the proxy itself and reached behind the same layers past any + // router. + let direct = choices.as_ref().and_then(|choices| { + let layered = |service: guard::BoxedService| { + guard::BoxedService::new( + tower::ServiceBuilder::new() + .map_response(|response: http::Response<_>| { + response.map(axum::body::Body::new) + }) + .layer(( + TraceLayer::new_for_http(), + cors.clone(), + client_address::ClientAddressLayer::with(guards.client_address.clone()), + )) + .service(guards.service(service, guard::Class::Transcoded)), + ) + }; + let direct = choices.direct(state.clone(), layered)?; + Some((direct, elsewhere.clone())) + }); + let chooser = choices.map(|choices| choices.with_elsewhere(elsewhere.clone())); + let router = router.with_state(state); + + Ok(Built { + router, + chooser, + direct, + cors, + guards, + }) } /// The guards the configuration and the hooks turn on, each with its /// scope, after the client-address resolution `resolver`; /// `maintenance_exempt` lists the paths maintenance mode leaves - /// reachable, `mounted` the `(method, path)` of every route. + /// reachable, `routed` the methods the routes answer. /// /// # Errors /// @@ -996,17 +1142,11 @@ impl ProxyServer { resolver: Arc, auth: Option>, maintenance_exempt: Vec, - mounted: &[(String, String)], + routed: guard::Routed<'_>, ) -> anyhow::Result { use config::Traffic::{Endpoints, Grpc, Transcoded}; - // `*` marks a route that answers every method, not a method. - let routed: Vec = mounted - .iter() - .filter(|(method, _)| method != "*") - .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok()) - .collect(); let scope = |config: Option<&ScopeConfig>, default: &[config::Traffic], what: &str| { - guard::Scope::compile(config, default, what, &routed).map_err(anyhow::Error::msg) + guard::Scope::compile(config, default, what, routed).map_err(anyhow::Error::msg) }; // A guard never sets what the client-address forwarding writes. let reserved: Arc<[http::HeaderName]> = resolver.configured_headers().cloned().collect(); @@ -1171,10 +1311,10 @@ impl ProxyServer { }) }) .collect::>>()?; - let layer = if config.origins.is_empty() { + if config.origins.is_empty() { tracing::warn!("CORS origins not set — using permissive CORS (dev mode)"); // Exposes every header already. - CorsLayer::permissive() + Ok(CorsLayer::any(config.max_age_secs)) } else { let origins = config .origins @@ -1200,17 +1340,12 @@ impl ProxyServer { // With credentials the Fetch standard (§3.2.5) forbids `*` for // methods and headers, so the preflight's own request is echoed // back instead: what the browser asked for, from an allowed origin. - CorsLayer::new() - .allow_origin(AllowOrigin::list(origins)) - .allow_methods(AllowMethods::mirror_request()) - .allow_headers(AllowHeaders::mirror_request()) - .allow_credentials(true) - .expose_headers(exposed.collect::>()) - }; - Ok(match config.max_age_secs { - Some(secs) => layer.max_age(std::time::Duration::from_secs(secs)), - None => layer, - }) + Ok(CorsLayer::listed( + origins, + &exposed.collect::>(), + config.max_age_secs, + )) + } } /// The [`ServeOptions`] of `listen:`: TLS from `listen.tls` (mTLS with its @@ -1340,6 +1475,7 @@ pub(crate) fn test_state() -> ProxyState { .connect_timeout(std::time::Duration::from_millis(100)) .connect_lazy(), forwarded_headers: Arc::from([]), + forwarded_names: Arc::from([]), sse_keep_alive_secs: 15, } } diff --git a/src/openapi.rs b/src/openapi.rs index a3722a8..a9343fd 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -11,10 +11,10 @@ use prost_reflect::{DescriptorPool, FieldDescriptor, Kind, MessageDescriptor, Me use serde_json::{json, Map, Value}; use crate::config::{AliasConfig, OpenApiConfig}; -use crate::transcode::httpbody; use crate::transcode::request::BodyMapping; use crate::transcode::rule::{self, HttpBinding, RouteMethod}; use crate::transcode::RpcSelection; +use crate::transcode::{self, httpbody, path}; /// The operations an OpenAPI 3.0 path item can hold, in the order a `*` rule /// lists them. @@ -38,6 +38,9 @@ pub fn generate( let mut tags = Vec::new(); let mut operation_ids = HashSet::new(); let http_ext = pool.get_extension_by_name("google.api.http"); + // The router paths the routes mount: a template the router cannot match, + // or one it refuses beside another, would promise a URL that is 404. + let mounted = transcode::mounted_shapes(pool, aliases, selection); for service in pool.services() { if !selection.is_all() && !service.methods().any(|m| selection.selects(&m)) { @@ -86,6 +89,10 @@ pub fn generate( } } for path in targets { + let mount = path::MountedPath::new(&path); + if mount.routable().is_err() || !mounted.contains(&mount.shape) { + continue; + } let mut operation = operation.clone(); if http_method == "head" { strip_response_content(&mut operation); diff --git a/src/openapi/tests.rs b/src/openapi/tests.rs index 0f0eb74..2490b5e 100644 --- a/src/openapi/tests.rs +++ b/src/openapi/tests.rs @@ -184,6 +184,18 @@ service Api { rpc Raw(google.api.HttpBody) returns (google.api.HttpBody) { option (google.api.http) = { post: "/v1/raw" body: "*" }; } + rpc Archive(Item) returns (Item) { + option (google.api.http) = { post: "/v1/items/{name}:archive" }; + } + rpc Broken(Item) returns (Item) { + option (google.api.http) = { get: "/v1/broken/a{name}b" }; + } + rpc Shelved(Item) returns (Item) { + option (google.api.http) = { get: "/v1/shelf/{name=foo%3Abar/*}/books" }; + } + rpc Shadowed(Item) returns (Item) { + option (google.api.http) = { get: "/v1/shelf/{name=**}" }; + } } "#; @@ -224,6 +236,48 @@ fn custom_head_rule_is_a_head_operation() { assert_eq!(item["head"]["parameters"][0]["in"], "path"); } +#[test] +fn custom_verb_after_a_variable_keeps_its_path_and_parameter() { + // OpenAPI path templating allows a template expression inside a segment, + // so the verb stays in the path and the variable is a path parameter. + let spec = spec(&[]); + let archive = &spec["paths"]["/v1/items/{name}:archive"]["post"]; + assert_eq!(archive["operationId"], "Api.Archive"); + let params = archive["parameters"].as_array().unwrap(); + let path_params: Vec<&Value> = params.iter().filter(|p| p["in"] == "path").collect(); + assert_eq!(path_params.len(), 1); + assert_eq!(path_params[0]["name"], "name"); + // No path item without the verb comes from this binding. + assert!(spec["paths"]["/v1/items/{name}"].get("post").is_none()); +} + +#[test] +fn a_route_the_router_refuses_beside_another_is_not_documented() { + // The escaped literal is a variable to the router, which then refuses the + // catch-all after it at that position: only the route it serves is in + // the spec. + let spec = spec(&[]); + let paths = spec["paths"].as_object().unwrap(); + assert!( + paths.contains_key("/v1/shelf/{name=foo%3Abar/*}/books"), + "{paths:?}" + ); + assert!(!paths.contains_key("/v1/shelf/{name=**}"), "{paths:?}"); +} + +#[test] +fn a_template_the_router_cannot_serve_is_not_documented() { + // The router skips `a{name}b`; the spec must not promise it either. + let spec = spec(&[]); + let paths = spec["paths"].as_object().unwrap(); + assert!(!paths.contains_key("/v1/broken/a{name}b"), "{paths:?}"); + assert!(!paths.values().any(|item| item + .as_object() + .unwrap() + .values() + .any(|op| op["operationId"] == "Api.Broken"))); +} + #[test] fn star_rule_is_listed_under_every_method_with_distinct_ids() { let spec = spec(&[]); diff --git a/src/service.rs b/src/service.rs index 3690311..1146339 100644 --- a/src/service.rs +++ b/src/service.rs @@ -8,6 +8,7 @@ use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; +use crate::cors::{Cors, CorsFuture, CorsLayer}; use axum::extract::connect_info::ConnectInfo; use axum::response::IntoResponse; use axum::routing::future::RouteFuture; @@ -16,10 +17,10 @@ use pin_project_lite::pin_project; use rustls::pki_types::CertificateDer; use tonic::transport::server::{Connected, TcpConnectInfo}; use tower::{Layer, Service, ServiceExt}; -use tower_http::cors::{Cors, CorsLayer}; use crate::client_address::ClientAddressLayer; use crate::guard::{BoxedService, Class, GrpcRejections, Guards}; +use crate::transcode::{Chooser, Direct}; /// tonic's `TlsConnectInfo`, the record its TLS server puts on /// a request and `Request::peer_certs` reads. tonic exports the name only with @@ -48,7 +49,7 @@ use crate::upstream::{ /// only the guards whose scope names them (`grpc`, `fallback`); a guard's /// rejection of a gRPC call is a gRPC status. /// -/// Serve it with [`serve`](crate::serve) or +/// Serve it with [`serve`](crate::serve()) or /// [`serve_with`](crate::serve_with) (TLS, a connection limit), or hand it to /// any server that takes a tower service of `http` types: a Unix socket, an /// existing hyper or axum server. Native gRPC needs HTTP/2 on that server @@ -72,8 +73,20 @@ use crate::upstream::{ /// ``` #[derive(Clone)] pub struct ProxyService { + /// Shared by every clone: a server clones the service for each request + /// and only one path serves it. + shared: Arc>, + /// The connection the requests arrive on, set per connection by the + /// server. + connection: Option, +} + +/// What every request of a [`ProxyService`] may take a path through. +#[derive(Clone)] +struct Shared { upstream: U, - routes: axum::Router, + /// Where the requests that are not gRPC go. + routing: Routing, /// Binary and text gRPC-Web to the upstream under the CORS policy, each /// built once; `None` when the upstream owns CORS for gRPC-Web. grpc_web: Option>, @@ -82,21 +95,68 @@ pub struct ProxyService { boxed: BoxedGrpc, /// The guards, for the fallback an embedder sets later. guards: Arc, - /// The connection the requests arrive on, set per connection by the - /// server. - connection: Option, } impl std::fmt::Debug for ProxyService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ProxyService") - .field("boxed_grpc", &self.boxed.grpc.is_some()) - .field("boxed_grpc_web", &self.boxed.web.is_some()) + .field("boxed_grpc", &self.shared.boxed.grpc.is_some()) + .field("boxed_grpc_web", &self.shared.boxed.web.is_some()) .field("connection", &self.connection) .finish_non_exhaustive() } } +/// How the requests that are not gRPC reach the proxy's routes. +#[derive(Clone)] +pub(crate) enum Routing { + /// The transcoded routes are matched by the proxy and served past any + /// router; the other routes by theirs. + Direct { + direct: Direct, + others: axum::Router, + }, + /// Every route by one router, which `chooser` (with transcoded routes) + /// chooses a binding after; `served` is the router with it. + Routed { + router: axum::Router, + chooser: Option, + served: axum::Router, + }, +} + +impl Routing { + /// Every route by `router`, choosing among its transcoded bindings with + /// `chooser`. + pub(crate) fn routed(router: axum::Router, chooser: Option) -> Self { + let served = match &chooser { + Some(chooser) => chooser.clone().layer(router.clone()), + None => router.clone(), + }; + Self::Routed { + router, + chooser, + served, + } + } + + /// These routes, with `fallback` answering what none does. + fn with_fallback(self, fallback: BoxedService) -> Self { + match self { + Self::Direct { direct, others } => Self::Direct { + direct, + others: others.fallback_service(fallback), + }, + Self::Routed { + router, chooser, .. + } => Self::routed( + router.fallback_service(fallback.clone()), + chooser.map(|chooser| chooser.with_fallback(fallback)), + ), + } + } +} + /// The gRPC paths that need more than a pass-through (guards, gRPC-Web /// translation), each protocol's stack built once; `None` for a protocol that /// passes through as it is. @@ -272,12 +332,12 @@ impl ConnectionInfo { } impl ProxyService { - /// The service over `upstream` and `routes`; gRPC-Web answers carry - /// `grpc_web_cors` when set, and gRPC-Web calls are translated to gRPC - /// when `translate_grpc_web`. + /// The service over `upstream` and the routes of `routing`; gRPC-Web + /// answers carry `grpc_web_cors` when set, and gRPC-Web calls are + /// translated to gRPC when `translate_grpc_web`. pub(crate) fn new( upstream: U, - routes: axum::Router, + routing: Routing, grpc_web_cors: Option, guards: Arc, translate_grpc_web: bool, @@ -324,11 +384,13 @@ impl ProxyService { web_text: cors.layer(forward(GrpcProtocol::WebText)), }); Self { - upstream, - routes, - grpc_web, - boxed, - guards, + shared: Arc::new(Shared { + upstream, + routing, + grpc_web, + boxed, + guards, + }), connection: None, } } @@ -338,9 +400,12 @@ impl ProxyService { /// tower service. Only the guards whose scope names `fallback` traffic see /// them; CORS and tracing are the fallback's own. /// - /// A request whose path a route answers but not with its method stays with - /// the proxy (`405`), as does every gRPC request and every browser - /// preflight for a gRPC-Web call, which follows the call it announces. + /// A URL whose path a transcoded template's route matches, but which no + /// binding answers (its field template or custom verb does not fit), is + /// the fallback's too. A request whose path a route answers but not with + /// its method stays with the proxy (`405`), as do every gRPC request and + /// every browser preflight for a gRPC-Web call, which follows the call it + /// announces. #[must_use] pub fn with_fallback(mut self, fallback: F) -> Self where @@ -348,16 +413,20 @@ impl ProxyService { F::Response: IntoResponse, F::Future: Send + 'static, { - // The routes' own layers do not reach a fallback set after them, so - // it gets the client-address resolution of its own, around its guards. - let resolve = ClientAddressLayer::with(self.guards.client_address.clone()); - self.routes = if self.guards.cover(Class::Fallback) { - let fallback = BoxedService::new(fallback.map_response(IntoResponse::into_response)); - self.routes - .fallback_service(resolve.layer(self.guards.service(fallback, Class::Fallback))) + let shared = Arc::make_mut(&mut self.shared); + let fallback = BoxedService::new(fallback.map_response(IntoResponse::into_response)); + let guarded = if shared.guards.cover(Class::Fallback) { + shared.guards.service(fallback, Class::Fallback) } else { - self.routes.fallback_service(resolve.layer(fallback)) + fallback }; + // The routes' own layers do not reach a fallback set after them, so + // it gets the client-address resolution of its own, around its guards. + // A URL no transcoded binding answers goes to the other routes, which + // hand what they do not answer to the same fallback. + let resolve = ClientAddressLayer::with(shared.guards.client_address.clone()); + let fallback = BoxedService::new(resolve.layer(guarded)); + shared.routing = shared.routing.clone().with_fallback(fallback); self } @@ -395,11 +464,7 @@ impl ProxyService { #[must_use] pub fn for_connection(&self, connection: impl Into) -> Self { Self { - upstream: self.upstream.clone(), - routes: self.routes.clone(), - grpc_web: self.grpc_web.clone(), - boxed: self.boxed.clone(), - guards: self.guards.clone(), + shared: self.shared.clone(), connection: Some(connection.into()), } } @@ -447,11 +512,13 @@ where let protocol = call.or_else(|| { is_grpc_web_preflight(request.method(), request.headers()).then_some(GrpcProtocol::Web) }); + // Only the path the request takes is cloned for it. + let shared = &*self.shared; // A preflight is not a call: it skips the guards and the translation. let boxed = match call { - Some(GrpcProtocol::Grpc) => self.boxed.grpc.as_mut(), - Some(GrpcProtocol::Web) => self.boxed.web.as_mut(), - Some(GrpcProtocol::WebText) => self.boxed.web_text.as_mut(), + Some(GrpcProtocol::Grpc) => shared.boxed.grpc.as_ref(), + Some(GrpcProtocol::Web) => shared.boxed.web.as_ref(), + Some(GrpcProtocol::WebText) => shared.boxed.web_text.as_ref(), None => None, }; let inner = if let Some(protocol) = protocol { @@ -461,7 +528,7 @@ where // its connection the way tonic's server hands it to a handler. let connection = connection_of(self.connection.as_ref(), &request); let peer = connection.as_ref().and_then(ConnectionInfo::remote_addr); - self.guards.client_address.apply(&mut request, peer); + shared.guards.client_address.apply(&mut request, peer); if let Some(connection) = connection { connection.into_tonic_extensions(request.extensions_mut()); } @@ -470,24 +537,24 @@ where // the translator are middleware, and `Forward` waits for the // upstream per request. Some(service) => Inner::Boxed { - future: service.call(request.map(axum::body::Body::new)), + future: service.clone().call(request.map(axum::body::Body::new)), }, None => { let request = request.map(tonic::body::Body::new); // A browser only speaks gRPC-Web, and the routes answered // its preflight: its call gets the same CORS policy. - let cors = match (&mut self.grpc_web, protocol) { - (Some(cors), GrpcProtocol::Web) => Some(&mut cors.web), - (Some(cors), GrpcProtocol::WebText) => Some(&mut cors.web_text), + let cors = match (&shared.grpc_web, protocol) { + (Some(cors), GrpcProtocol::Web) => Some(&cors.web), + (Some(cors), GrpcProtocol::WebText) => Some(&cors.web_text), _ => None, }; match cors { // `Forward` is always ready, and so is CORS around it. Some(cors) => Inner::GrpcWeb { - future: cors.call(request), + future: cors.clone().call(request), }, None => Inner::Grpc { - call: PassThrough::new(self.upstream.clone(), request, protocol), + call: PassThrough::new(shared.upstream.clone(), request, protocol), }, } } @@ -504,8 +571,26 @@ where } else if let Some(connection) = connection_of(None, &request) { request.extensions_mut().insert(connection); } - Inner::Routes { - future: self.routes.call(request), + // A router and the transcoded routes' layers are always ready. + // A router is a reference count. + match &shared.routing { + Routing::Direct { direct, others } => { + if direct.takes(&mut request) { + Inner::Boxed { + future: direct + .service() + .clone() + .call(request.map(axum::body::Body::new)), + } + } else { + Inner::Routes { + future: others.clone().call(request), + } + } + } + Routing::Routed { served, .. } => Inner::Routes { + future: served.clone().call(request), + }, } }; ResponseFuture { inner } @@ -533,7 +618,7 @@ pin_project! { }, GrpcWeb { #[pin] - future: tower_http::cors::ResponseFuture>, + future: CorsFuture, axum::body::Body>, }, Boxed { #[pin] diff --git a/src/service/tests.rs b/src/service/tests.rs index e06ba62..c7c6657 100644 --- a/src/service/tests.rs +++ b/src/service/tests.rs @@ -61,7 +61,13 @@ impl Service> for Recorder { /// A proxy with one HTTP route, `GET /route`, in front of `upstream`. fn service(upstream: Recorder) -> ProxyService { let routes = axum::Router::new().route("/route", get(|| async { "route" })); - ProxyService::new(upstream, routes, None, Arc::default(), false) + ProxyService::new( + upstream, + Routing::routed(routes, None), + None, + Arc::default(), + false, + ) } fn grpc_request(path: &str) -> http::Request { @@ -289,7 +295,7 @@ fn peer_routes() -> axum::Router { async fn an_http_request_carries_its_peer_for_the_middleware() { let proxy = ProxyService::new( Recorder::default(), - peer_routes(), + Routing::routed(peer_routes(), None), None, Arc::default(), false, @@ -306,7 +312,7 @@ async fn an_http_request_carries_its_peer_for_the_middleware() { async fn an_http_request_carries_its_connection_for_the_transcoder() { let proxy = ProxyService::new( Recorder::default(), - peer_routes(), + Routing::routed(peer_routes(), None), None, Arc::default(), false, @@ -327,7 +333,7 @@ async fn an_http_request_carries_its_connection_for_the_transcoder() { async fn an_http_request_without_a_connection_carries_none() { let proxy = ProxyService::new( Recorder::default(), - peer_routes(), + Routing::routed(peer_routes(), None), None, Arc::default(), false, @@ -361,7 +367,7 @@ async fn a_grpc_request_on_an_axum_server_carries_its_peer_to_the_upstream() { async fn an_http_request_on_an_axum_server_carries_its_peer_for_the_transcoder() { let proxy = ProxyService::new( Recorder::default(), - peer_routes(), + Routing::routed(peer_routes(), None), None, Arc::default(), false, diff --git a/src/shield/matcher.rs b/src/shield/matcher.rs index c723c55..6ab1523 100644 --- a/src/shield/matcher.rs +++ b/src/shield/matcher.rs @@ -64,9 +64,26 @@ pub struct CompiledRule { /// fingerprint rules and to de-identify key values before they reach the shared /// store, and deterministic across instances so reconciliation keys agree. pub fn short_hash(input: &str) -> String { + let hex = short_hash_hex(input.as_bytes()); + // The hex digits are ASCII. + String::from_utf8(hex.to_vec()).expect("hex digits are ASCII") +} + +/// [`short_hash`] of `input` as its 32 lower-case hex digits, built without +/// allocating. +pub(crate) fn short_hash_hex(input: &[u8]) -> [u8; 32] { use sha2::{Digest, Sha256}; - let digest = Sha256::digest(input.as_bytes()); - digest[..16].iter().map(|b| format!("{b:02x}")).collect() + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(input); + let mut hex = [0u8; 32]; + let (pairs, _) = hex.as_chunks_mut::<2>(); + for (pair, byte) in pairs.iter_mut().zip(&digest[..16]) { + *pair = [ + DIGITS[usize::from(byte >> 4)], + DIGITS[usize::from(byte & 0x0f)], + ]; + } + hex } /// Build a glob matcher where `*` stays within a path segment and `**` spans diff --git a/src/shield/mod.rs b/src/shield/mod.rs index 68e888a..7afb0ba 100644 --- a/src/shield/mod.rs +++ b/src/shield/mod.rs @@ -24,7 +24,8 @@ use std::sync::Arc; use std::time::Duration; use axum::extract::Request; -use axum::http::HeaderMap; +use axum::http::header::RETRY_AFTER; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::middleware::Next; use axum::response::Response; @@ -136,6 +137,11 @@ impl Shield { .find(|r| r.phase == phase && r.matcher.is_match(path)) } + /// Whether a rule runs in `phase`: a phase without one needs no layer. + pub(crate) fn enforces(&self, phase: Phase) -> bool { + self.rules.iter().any(|rule| rule.phase == phase) + } + /// Resolve the limit tier for a matched rule, in priority order: the JWT /// itself (validated claims), then the external service (cached), then the /// rule's pinned profile, then the default profile. `None` means no limit @@ -192,112 +198,160 @@ pub async fn post_auth_middleware( /// Match a phase's rule for the request, apply its limit, and attach headers. async fn enforce(shield: &Shield, phase: Phase, request: Request, next: Next) -> Response { - let path = request.uri().path(); - let Some(rule) = shield.match_rule(path, phase) else { - return next.run(request).await; - }; - - // The proxy resolved the client address before any guard. Shield used on - // its own, in a router of the embedder's, gets it from a - // `ClientAddressLayer` in front of it; without one it falls back to what - // resolution gives with no trusted proxy: the peer an axum server records. - let unresolved; - let client = match request.extensions().get::() { - Some(client) => client, - None => { - let peer = request - .extensions() - .get::>() - .map(|ci| ci.0); - unresolved = ClientAddress::from_peer(peer); - &unresolved + match shield.decide(phase, &request) { + Decision::Pass => next.run(request).await, + Decision::Reject(response) => response, + Decision::Report(report) => { + let mut response = next.run(request).await; + report.apply(&mut response); + response } - }; - let claims = request - .extensions() - .get::() - .map(|c| c.0.as_ref()); - let key = rule_key( - &rule.fingerprint, - &rule.key, - client, - request.headers(), - claims, - ); + } +} - // The limit service resolves per-principal, so it needs the real identity; - // the store / shared counter only need the de-identified `store` key. - let Some(profile) = shield.resolve_limit(rule, claims, &key.identity) else { - // No limit resolves for this rule (JWT/service/profile/default all - // absent): allow the request unmetered. - return next.run(request).await; - }; +/// What a phase's limit makes of a request. +pub(crate) enum Decision { + /// No limit applies. + Pass, + /// Over the limit: the answer. + Reject(Response), + /// Admitted: the budget its response reports. + Report(Report), +} - // Fleet gate first (read-only, cached), so the local shaper is not charged - // for a request the fleet-wide budget will reject. The fleet budget is the - // sustained rate (`profile.limit`); `burst` is deliberately a per-instance - // smoothing allowance, not a fleet-wide entitlement (honouring it fleet-wide - // would multiply the effective limit by N). A single instance's burst is - // therefore capped by the shared budget when the fleet is near it. - // - // Consequence for a `burst > limit` profile (e.g. `rate: "1/min", burst: 5`): - // under reconciliation the shared counter caps the key at the sustained - // `limit`, so the extra burst headroom that a local-only deployment would - // allow is not granted fleet-wide. This is intentional, not an oversight: - // gating on `burst` instead would let the fleet sustain `burst`-per-window, - // loosening the sustained cap by the burst factor. The conservative choice - // (never over-admit the fleet's sustained budget) wins; the local GCRA still - // smooths per-instance traffic. - #[cfg(feature = "redis")] - let fleet_remaining = shield - .global - .as_ref() - .map(|g| g.fleet_remaining(&key.store, profile.limit, profile.window)); - #[cfg(feature = "redis")] - if fleet_remaining == Some(0) { - return global_reject(profile.limit, profile.window); +/// The budget an admitted request's response reports. +pub(crate) struct Report { + limit: u64, + remaining: u64, + verdict: Verdict, +} + +impl Report { + /// Report the budget on `response`: the tightest one across phases. With + /// defense-in-depth (a pre-auth and a post-auth rule on the same path), an + /// inner limiter may already have set headers on the way out; they are + /// overwritten only when this (outer) phase's remaining is smaller, so the + /// client always sees the budget that will bite first. An inner rejection + /// carries remaining 0, so it is never overwritten. + pub(crate) fn apply(&self, response: &mut Response) { + maybe_tighten_rate_headers( + response.headers_mut(), + self.limit, + self.remaining, + &self.verdict, + ); } +} - // The store key intentionally excludes the profile's numbers, so if a key's - // resolved tier changes (a JWT/service tier upgrade), the existing TAT is - // reused with the new emission interval. The TAT is an absolute time, so this - // only causes a brief transient at the change and self-corrects within one - // window. Keying by the tier's numbers instead would reset the budget on - // every tier flip, which a client could exploit to shed its own limit. - let verdict = shield.store.check(&key.store, &profile.gcra); - if !verdict.allowed { - return too_many_requests(profile.limit, verdict.remaining, &verdict); +impl Shield { + /// Match a phase's rule for `request` and charge its limit. + pub(crate) fn decide(&self, phase: Phase, request: &http::Request) -> Decision { + let path = request.uri().path(); + let Some(rule) = self.match_rule(path, phase) else { + return Decision::Pass; + }; + self.charge(rule, request) } - // Report the tighter of the local and (when reconciled) fleet budgets, so a - // client near the fleet cap isn't told it has ample local room. - #[cfg(not(feature = "redis"))] - let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window); - #[cfg(feature = "redis")] - let (reported, header_verdict) = { - // Record the admit for the next reconciliation push (whenever the fleet - // gate is active, i.e. `fleet_remaining` was computed). - if fleet_remaining.is_some() { - if let Some(global) = &shield.global { - global.record(&key.store, profile.window); + /// Charge `request` to the limit of `rule`, which its path matched. + fn charge(&self, rule: &CompiledRule, request: &http::Request) -> Decision { + // The proxy resolved the client address before any guard. Shield used on + // its own, in a router of the embedder's, gets it from a + // `ClientAddressLayer` in front of it; without one it falls back to what + // resolution gives with no trusted proxy: the peer an axum server records. + let unresolved; + let client = match request.extensions().get::() { + Some(client) => client, + None => { + let peer = request + .extensions() + .get::>() + .map(|ci| ci.0); + unresolved = ClientAddress::from_peer(peer); + &unresolved } + }; + let claims = request + .extensions() + .get::() + .map(|c| c.0.as_ref()); + let key = rule_key( + &rule.fingerprint, + &rule.key, + client, + request.headers(), + claims, + ); + + // The limit service resolves per-principal, so it needs the real identity; + // the store / shared counter only need the de-identified `store` key. + let Some(profile) = self.resolve_limit(rule, claims, key.identity.as_str()) else { + // No limit resolves for this rule (JWT/service/profile/default all + // absent): allow the request unmetered. + return Decision::Pass; + }; + + // Fleet gate first (read-only, cached), so the local shaper is not charged + // for a request the fleet-wide budget will reject. The fleet budget is the + // sustained rate (`profile.limit`); `burst` is deliberately a per-instance + // smoothing allowance, not a fleet-wide entitlement (honouring it fleet-wide + // would multiply the effective limit by N). A single instance's burst is + // therefore capped by the shared budget when the fleet is near it. + // + // Consequence for a `burst > limit` profile (e.g. `rate: "1/min", burst: 5`): + // under reconciliation the shared counter caps the key at the sustained + // `limit`, so the extra burst headroom that a local-only deployment would + // allow is not granted fleet-wide. This is intentional, not an oversight: + // gating on `burst` instead would let the fleet sustain `burst`-per-window, + // loosening the sustained cap by the burst factor. The conservative choice + // (never over-admit the fleet's sustained budget) wins; the local GCRA still + // smooths per-instance traffic. + #[cfg(feature = "redis")] + let fleet_remaining = self + .global + .as_ref() + .map(|g| g.fleet_remaining(key.store.as_str(), profile.limit, profile.window)); + #[cfg(feature = "redis")] + if fleet_remaining == Some(0) { + return Decision::Reject(global_reject(profile.limit, profile.window)); } - reconciled_headers(verdict, fleet_remaining, profile.window) - }; - let mut response = next.run(request).await; - // Report the tightest budget across phases. With defense-in-depth (a pre-auth - // and a post-auth rule on the same path), an inner limiter may already have - // set headers on the way out; overwrite them only when this (outer) phase's - // remaining is smaller, so the client always sees the budget that will bite - // first. An inner rejection carries remaining 0, so it is never overwritten. - maybe_tighten_rate_headers( - response.headers_mut(), - profile.limit, - reported, - &header_verdict, - ); - response + // The store key intentionally excludes the profile's numbers, so if a key's + // resolved tier changes (a JWT/service tier upgrade), the existing TAT is + // reused with the new emission interval. The TAT is an absolute time, so this + // only causes a brief transient at the change and self-corrects within one + // window. Keying by the tier's numbers instead would reset the budget on + // every tier flip, which a client could exploit to shed its own limit. + let verdict = self.store.check(key.store.as_str(), &profile.gcra); + if !verdict.allowed { + return Decision::Reject(too_many_requests( + profile.limit, + verdict.remaining, + &verdict, + )); + } + + // Report the tighter of the local and (when reconciled) fleet budgets, so a + // client near the fleet cap isn't told it has ample local room. + #[cfg(not(feature = "redis"))] + let (reported, header_verdict) = reconciled_headers(verdict, None, profile.window); + #[cfg(feature = "redis")] + let (reported, header_verdict) = { + // Record the admit for the next reconciliation push (whenever the fleet + // gate is active, i.e. `fleet_remaining` was computed). + if fleet_remaining.is_some() { + if let Some(global) = &self.global { + global.record(key.store.as_str(), profile.window); + } + } + reconciled_headers(verdict, fleet_remaining, profile.window) + }; + Decision::Report(Report { + limit: profile.limit, + remaining: reported, + verdict: header_verdict, + }) + } } /// Combine the local GCRA `verdict` with the optional fleet remaining into the @@ -348,18 +402,18 @@ fn maybe_tighten_rate_headers( remaining: u64, verdict: &Verdict, ) { - let header_u64 = |name: &str| { + let header_u64 = |name: &HeaderName| { headers .get(name) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) }; - let keep_inner = match header_u64("ratelimit-remaining") { + let keep_inner = match header_u64(&RATELIMIT_REMAINING) { Some(inner) if inner < remaining => true, // Tie on remaining: keep the inner headers only if their reset is at // least as long as this phase's, so the longer-binding budget survives. Some(inner) if inner == remaining => { - header_u64("ratelimit-reset").unwrap_or(0) >= secs_ceil(verdict.reset_after) + header_u64(&RATELIMIT_RESET).unwrap_or(0) >= secs_ceil(verdict.reset_after) } _ => false, }; @@ -372,15 +426,13 @@ fn maybe_tighten_rate_headers( // Only ever widen: a 200 has no `Retry-After` to touch, and an already // longer wait is left intact. if let Some(current) = headers - .get("retry-after") + .get(RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) { let reset = secs_ceil(verdict.reset_after); if reset > current { - if let Ok(v) = reset.to_string().parse() { - headers.insert("retry-after", v); - } + headers.insert(RETRY_AFTER, HeaderValue::from(reset)); } } } @@ -404,41 +456,118 @@ fn global_reject(limit: u64, window: Duration) -> Response { } /// The keys a matched rule derives for one request. -struct RuleKey { +struct RuleKey<'r> { /// De-identified key for the local store and shared counter: the rule's /// stable fingerprint, the source tag, and a hash of the value. Raw client /// values (API keys, principals, IPs) never reach the shared store or its /// logs. Deterministic across instances so reconciliation keys agree. - store: String, + store: StoreKey, /// The raw identity for per-principal limit-service resolution (the service /// must see the real principal to resolve its tier). Not persisted. - identity: String, + identity: Identity<'r>, +} + +/// A store key, `fingerprint:tag:hash`, held inline: the fingerprint and the +/// hash are 32 hex digits each and the tag at most three letters. +struct StoreKey { + bytes: [u8; 72], + len: usize, +} + +impl StoreKey { + fn new(fingerprint: &str, tag: &str, hash: &[u8; 32]) -> Self { + let mut key = Self { + bytes: [0; 72], + len: 0, + }; + for part in [fingerprint.as_bytes(), b":", tag.as_bytes(), b":", hash] { + key.bytes[key.len..key.len + part.len()].copy_from_slice(part); + key.len += part.len(); + } + key + } + + fn as_str(&self) -> &str { + std::str::from_utf8(&self.bytes[..self.len]).expect("hex digits, a tag and colons") + } +} + +/// The value a rule keys a request's limit by, borrowed from the request where +/// it can be. +enum Identity<'r> { + Address(AddressText), + Borrowed(&'r str), + Owned(String), +} + +impl Identity<'_> { + fn as_str(&self) -> &str { + match self { + Identity::Address(address) => address.as_str(), + Identity::Borrowed(text) => text, + Identity::Owned(text) => text, + } + } +} + +/// An IP address as text, held inline: an IPv6 address is at most 45 +/// characters (RFC 4291 §2.2, with an embedded IPv4 address). +struct AddressText { + bytes: [u8; 45], + len: usize, +} + +impl AddressText { + fn new(ip: std::net::IpAddr) -> Self { + use std::fmt::Write; + let mut text = Self { + bytes: [0; 45], + len: 0, + }; + write!(text, "{ip}").expect("an IP address fits in 45 characters"); + text + } + + fn as_str(&self) -> &str { + std::str::from_utf8(&self.bytes[..self.len]).expect("an address is written as text") + } +} + +impl std::fmt::Write for AddressText { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let end = self.len + s.len(); + let slot = self.bytes.get_mut(self.len..end).ok_or(std::fmt::Error)?; + slot.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } } /// Derive the store key and resolution identity for a matched rule. Every source /// falls back to the client address when its value is absent, so a limit can't /// be dodged by omitting a header or authenticating anonymously (subject to the /// rule's phase: a `jwt_claim` rule only runs post-auth). -fn rule_key( +fn rule_key<'r>( fingerprint: &str, key: &KeySource, client: &ClientAddress, - headers: &HeaderMap, + headers: &'r HeaderMap, claims: Option<&serde_json::Value>, -) -> RuleKey { +) -> RuleKey<'r> { let (tag, identity) = match key { KeySource::Ip => ("ip", address_identity(client)), KeySource::Header(name) => match header_str(headers, name) { - Some(v) => ("hdr", v), + Some(v) => ("hdr", Identity::Borrowed(v)), None => ("ip", address_identity(client)), }, KeySource::JwtClaim(claim) => match claims.and_then(|c| resolve::claim_str(c, claim)) { - Some(v) => ("jwt", v), + Some(v) => ("jwt", Identity::Owned(v)), None => ("ip", address_identity(client)), }, }; + let hash = matcher::short_hash_hex(identity.as_str().as_bytes()); RuleKey { - store: format!("{fingerprint}:{tag}:{}", matcher::short_hash(&identity)), + store: StoreKey::new(fingerprint, tag, &hash), identity, } } @@ -447,37 +576,37 @@ fn rule_key( /// bucket per reason none resolved. A request that cannot name its client /// shares its budget with every other such request rather than escaping the /// limit, and a proxy's own address never stands in for the client's. -fn address_identity(client: &ClientAddress) -> String { +fn address_identity(client: &ClientAddress) -> Identity<'static> { match client.resolution() { - Resolution::Peer(ip) | Resolution::Forwarded(ip) => ip.to_string(), - Resolution::Invalid(_) => "invalid".to_string(), - Resolution::Unavailable => "unknown".to_string(), + Resolution::Peer(ip) | Resolution::Forwarded(ip) => Identity::Address(AddressText::new(ip)), + Resolution::Invalid(_) => Identity::Borrowed("invalid"), + Resolution::Unavailable => Identity::Borrowed("unknown"), } } /// Trimmed, non-empty value of a header. -fn header_str(headers: &HeaderMap, name: &str) -> Option { +fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> { headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|s| !s.is_empty()) - .map(str::to_string) } +static RATELIMIT_LIMIT: HeaderName = HeaderName::from_static("ratelimit-limit"); +static RATELIMIT_REMAINING: HeaderName = HeaderName::from_static("ratelimit-remaining"); +static RATELIMIT_RESET: HeaderName = HeaderName::from_static("ratelimit-reset"); + /// Attach the draft-ietf `RateLimit-*` headers describing the remaining budget. /// `remaining` is passed explicitly (rather than read from the verdict) so the /// caller can report the tighter of the local and fleet budgets. fn attach_rate_headers(headers: &mut HeaderMap, limit: u64, remaining: u64, verdict: &Verdict) { - if let Ok(v) = limit.to_string().parse() { - headers.insert("ratelimit-limit", v); - } - if let Ok(v) = remaining.to_string().parse() { - headers.insert("ratelimit-remaining", v); - } - if let Ok(v) = secs_ceil(verdict.reset_after).to_string().parse() { - headers.insert("ratelimit-reset", v); - } + headers.insert(RATELIMIT_LIMIT.clone(), HeaderValue::from(limit)); + headers.insert(RATELIMIT_REMAINING.clone(), HeaderValue::from(remaining)); + headers.insert( + RATELIMIT_RESET.clone(), + HeaderValue::from(secs_ceil(verdict.reset_after)), + ); } /// A `429` response carrying the rate-limit headers plus `Retry-After`. @@ -485,9 +614,10 @@ fn too_many_requests(limit: u64, remaining: u64, verdict: &Verdict) -> Response let mut response = crate::guard::reject(tonic::Code::ResourceExhausted, "rate limit exceeded"); let headers = response.headers_mut(); attach_rate_headers(headers, limit, remaining, verdict); - if let Ok(v) = secs_ceil(verdict.retry_after).to_string().parse() { - headers.insert("retry-after", v); - } + headers.insert( + RETRY_AFTER, + HeaderValue::from(secs_ceil(verdict.retry_after)), + ); response } diff --git a/src/shield/store.rs b/src/shield/store.rs index 2ff330b..9f721ed 100644 --- a/src/shield/store.rs +++ b/src/shield/store.rs @@ -67,6 +67,14 @@ impl GcraStore { let now = self.now(); self.maybe_sweep(now); + // A key seen before is updated in place, with no owned copy of it. + if let Some(mut tat) = self.tats.get_mut(key) { + let verdict = gcra.check(Some(Duration::from_nanos(*tat)), now); + if verdict.allowed { + *tat = dur_nanos(verdict.new_tat); + } + return verdict; + } match self.tats.entry(key.to_string()) { Entry::Occupied(mut o) => { let stored = Some(Duration::from_nanos(*o.get())); diff --git a/src/shield/tests.rs b/src/shield/tests.rs index 46775d8..bbcc5ae 100644 --- a/src/shield/tests.rs +++ b/src/shield/tests.rs @@ -183,53 +183,63 @@ async fn rule_without_resolvable_profile_passes() { #[test] fn jwt_claim_key_uses_claim_then_falls_back_to_ip() { let claims = serde_json::json!({ "sub": "alice", "org": { "id": "acme" } }); + let none = HeaderMap::new(); let key = KeySource::JwtClaim("sub".to_string()); // Present claim: identity is the raw claim value (for service resolution); // the store key is de-identified (no raw value) and tagged `jwt`. let ip = client("1.1.1.1"); - let k = rule_key("fp", &key, &ip, &HeaderMap::new(), Some(&claims)); - assert_eq!(k.identity, "alice"); - assert!(k.store.starts_with("fp:jwt:")); + let k = rule_key("fp", &key, &ip, &none, Some(&claims)); + assert_eq!(k.identity.as_str(), "alice"); + assert!(k.store.as_str().starts_with("fp:jwt:")); assert!( - !k.store.contains("alice"), + !k.store.as_str().contains("alice"), "raw value must not appear in store key" ); // Dotted path into a nested claim. let nested = KeySource::JwtClaim("org.id".to_string()); assert_eq!( - rule_key("fp", &nested, &ip, &HeaderMap::new(), Some(&claims)).identity, + rule_key("fp", &nested, &ip, &none, Some(&claims)) + .identity + .as_str(), "acme" ); // No claims (anonymous) → IP fallback, so the limit can't be dodged. - let anon = rule_key("fp", &key, &ip, &HeaderMap::new(), None); - assert_eq!(anon.identity, "1.1.1.1"); - assert!(anon.store.starts_with("fp:ip:")); + let anon = rule_key("fp", &key, &ip, &none, None); + assert_eq!(anon.identity.as_str(), "1.1.1.1"); + assert!(anon.store.as_str().starts_with("fp:ip:")); // Claim present but missing the requested field → IP fallback. let missing = rule_key( "fp", &KeySource::JwtClaim("missing".to_string()), &ip, - &HeaderMap::new(), + &none, Some(&claims), ); - assert_eq!(missing.identity, "1.1.1.1"); - assert!(missing.store.starts_with("fp:ip:")); + assert_eq!(missing.identity.as_str(), "1.1.1.1"); + assert!(missing.store.as_str().starts_with("fp:ip:")); } #[test] fn store_key_namespaced_by_fingerprint_and_value() { let key = KeySource::Ip; + let none = HeaderMap::new(); let one = client("1.1.1.1"); // Same client under two different rules (fingerprints) → independent budgets. - let a = rule_key("fpA", &key, &one, &HeaderMap::new(), None); - let b = rule_key("fpB", &key, &one, &HeaderMap::new(), None); - assert_ne!(a.store, b.store); + let a = rule_key("fpA", &key, &one, &none, None); + let b = rule_key("fpB", &key, &one, &none, None); + assert_ne!(a.store.as_str(), b.store.as_str()); // Same rule + same value → identical store key (stable across instances). - let a2 = rule_key("fpA", &key, &one, &HeaderMap::new(), None); - assert_eq!(a.store, a2.store); + let a2 = rule_key("fpA", &key, &one, &none, None); + assert_eq!(a.store.as_str(), a2.store.as_str()); // Different value → different store key. - let c = rule_key("fpA", &key, &client("2.2.2.2"), &HeaderMap::new(), None); - assert_ne!(a.store, c.store); + let c = rule_key("fpA", &key, &client("2.2.2.2"), &none, None); + assert_ne!(a.store.as_str(), c.store.as_str()); + // The key is the one the shared store has always been given: the + // fingerprint, the tag and the hash of the value, as text. + assert_eq!( + a.store.as_str(), + format!("fpA:ip:{}", matcher::short_hash("1.1.1.1")) + ); } #[test] @@ -245,11 +255,12 @@ fn an_unresolved_address_never_keys_by_the_proxys_own_address() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", "garbage".parse().unwrap()); let invalid = resolver.resolve(Some("10.0.0.1:4000".parse().unwrap()), &headers); - let key = rule_key("fp", &KeySource::Ip, &invalid, &HeaderMap::new(), None); - assert_eq!(key.identity, "invalid"); + let none = HeaderMap::new(); + let key = rule_key("fp", &KeySource::Ip, &invalid, &none, None); + assert_eq!(key.identity.as_str(), "invalid"); let unavailable = ClientAddress::from_peer(None); - let key = rule_key("fp", &KeySource::Ip, &unavailable, &HeaderMap::new(), None); - assert_eq!(key.identity, "unknown"); + let key = rule_key("fp", &KeySource::Ip, &unavailable, &none, None); + assert_eq!(key.identity.as_str(), "unknown"); } #[tokio::test] diff --git a/src/transcode/metadata.rs b/src/transcode/metadata.rs index cafe3d7..c769c80 100644 --- a/src/transcode/metadata.rs +++ b/src/transcode/metadata.rs @@ -49,17 +49,35 @@ pub fn try_http_headers_to_grpc_metadata( headers: &HeaderMap, forwarded_headers: &[String], ) -> Result { - checked(headers, forwarded_headers, Source::Client) + checked( + headers, + Forwarded::Listed(forwarded_headers), + Source::Client, + ) } -/// [`try_http_headers_to_grpc_metadata`] for a request whose client-address -/// headers the proxy already rewrote: `forwarded_headers` names them too, and -/// they are forwarded as the rewrite left them. -pub(crate) fn rewritten_headers_to_grpc_metadata( +/// The headers a request forwards: as configured, or already parsed. +#[derive(Clone, Copy)] +pub(crate) enum Forwarded<'a> { + Listed(&'a [String]), + Named(&'a [HeaderName]), +} + +/// [`try_http_headers_to_grpc_metadata`] of the `forwarded` headers, for a +/// request whose client-address headers the proxy rewrote (`rewritten`): +/// `forwarded` names them too, and they are forwarded as the rewrite left +/// them. +pub(crate) fn forwarded_metadata( headers: &HeaderMap, - forwarded_headers: &[String], + forwarded: Forwarded<'_>, + rewritten: bool, ) -> Result { - checked(headers, forwarded_headers, Source::Rewritten) + let source = if rewritten { + Source::Rewritten + } else { + Source::Client + }; + checked(headers, forwarded, source) } /// Who last wrote a request's client-address headers. @@ -73,10 +91,10 @@ enum Source { fn checked( headers: &HeaderMap, - forwarded_headers: &[String], + forwarded: Forwarded<'_>, source: Source, ) -> Result { - forward(headers, forwarded_headers, source, |name, value| { + forward(headers, forwarded, source, |name, value| { if carries(name, value.as_bytes()) { Ok(()) } else { @@ -97,7 +115,8 @@ pub fn http_headers_to_grpc_metadata( headers: &HeaderMap, forwarded_headers: &[String], ) -> MetadataMap { - match forward::(headers, forwarded_headers, Source::Client, |_, _| Ok(())) { + let forwarded = Forwarded::Listed(forwarded_headers); + match forward::(headers, forwarded, Source::Client, |_, _| Ok(())) { Ok(metadata) => metadata, Err(never) => match never {}, } @@ -106,72 +125,95 @@ pub fn http_headers_to_grpc_metadata( /// The metadata for `headers`, with `check` deciding on each forwarded value. fn forward( headers: &HeaderMap, - forwarded_headers: &[String], + forwarded: Forwarded<'_>, source: Source, mut check: impl FnMut(&HeaderName, &HeaderValue) -> Result<(), E>, ) -> Result { - let mut forwarded = HeaderMap::new(); - for name in forwarded_headers { - // Trace-context propagation owns these, listed or not, and - // client-address resolution owns the address headers a client sent. - if is_trace_context(name) || (source == Source::Client && crate::client_address::owns(name)) - { - continue; - } - let values = headers.get_all(name.as_str()); - if values.iter().next().is_none() { - continue; - } - let name = HeaderName::from_bytes(name.as_bytes()) - .expect("a name the request carries a header under is a valid header name"); - // A name gRPC metadata cannot carry is refused when the proxy is built; - // a direct caller that lists one gets nothing forwarded under it. - if !is_grpc_key(name.as_str()) { - continue; - } - // A name listed twice is forwarded once, so its values are not doubled. - let Entry::Vacant(vacant) = forwarded.entry(name) else { - continue; - }; - let key = vacant.key().clone(); - let binary = key.as_str().ends_with("-bin"); - let mut vacant = Some(vacant); - let mut entry = None; - let mut push = |value: HeaderValue| match entry.as_mut() { - Some(entry) => { - OccupiedEntry::append(entry, value); - } - None => { - let vacant = vacant - .take() - .expect("only the first value finds the entry vacant"); - entry = Some(vacant.insert_entry(value)); - } - }; - for value in values { - check(&key, value)?; - // gRPC PROTOCOL-HTTP2 lets binary values be joined by commas, and - // tonic does not split them before decoding: each part travels as - // its own value. - if binary && value.as_bytes().contains(&b',') { - for part in value.as_bytes().split(|&b| b == b',') { - push( - HeaderValue::from_bytes(part.trim_ascii()) - .expect("a trimmed part of a header value is a header value"), - ); + let mut metadata = HeaderMap::new(); + match forwarded { + Forwarded::Listed(names) => { + for name in names { + // A name a request cannot carry a header under forwards + // nothing. + if let Ok(name) = HeaderName::from_bytes(name.as_bytes()) { + forward_one(&mut metadata, headers, &name, source, &mut check)?; } - } else { - push(value.clone()); + } + } + Forwarded::Named(names) => { + for name in names { + forward_one(&mut metadata, headers, name, source, &mut check)?; } } } - let mut metadata = MetadataMap::from_headers(forwarded); + let mut metadata = MetadataMap::from_headers(metadata); inject_trace_context(&mut metadata, headers); Ok(metadata) } +/// Forward every value of the header `name` from `headers` to `forwarded`, +/// with `check` deciding on each. +fn forward_one( + forwarded: &mut HeaderMap, + headers: &HeaderMap, + name: &HeaderName, + source: Source, + check: &mut impl FnMut(&HeaderName, &HeaderValue) -> Result<(), E>, +) -> Result<(), E> { + let text = name.as_str(); + // Trace-context propagation owns these, listed or not, and client-address + // resolution owns the address headers a client sent. + if is_trace_context(text) || (source == Source::Client && crate::client_address::owns(text)) { + return Ok(()); + } + let values = headers.get_all(name); + if values.iter().next().is_none() { + return Ok(()); + } + // A name gRPC metadata cannot carry is refused when the proxy is built; + // a direct caller that lists one gets nothing forwarded under it. + if !is_grpc_key(text) { + return Ok(()); + } + // A name listed twice is forwarded once, so its values are not doubled. + let Entry::Vacant(vacant) = forwarded.entry(name.clone()) else { + return Ok(()); + }; + let binary = text.ends_with("-bin"); + let mut vacant = Some(vacant); + let mut entry = None; + let mut push = |value: HeaderValue| match entry.as_mut() { + Some(entry) => { + OccupiedEntry::append(entry, value); + } + None => { + let vacant = vacant + .take() + .expect("only the first value finds the entry vacant"); + entry = Some(vacant.insert_entry(value)); + } + }; + for value in values { + check(name, value)?; + // gRPC PROTOCOL-HTTP2 lets binary values be joined by commas, and + // tonic does not split them before decoding: each part travels as its + // own value. + if binary && value.as_bytes().contains(&b',') { + for part in value.as_bytes().split(|&b| b == b',') { + push( + HeaderValue::from_bytes(part.trim_ascii()) + .expect("a trimmed part of a header value is a header value"), + ); + } + } else { + push(value.clone()); + } + } + Ok(()) +} + /// Whether gRPC metadata can carry `value` under `name` (gRPC PROTOCOL-HTTP2, /// "Custom-Metadata"): base64 for a `-bin` key, visible ASCII and space for a /// text key. @@ -282,21 +324,28 @@ fn append_ascii(metadata: &mut MetadataMap, key: &'static str, value: &[u8]) { /// well-formed per W3C §3.2.2; otherwise (missing or malformed) synthesizes a /// fresh one so the upstream always receives a single valid, joinable trace. fn inject_trace_context(metadata: &mut MetadataMap, headers: &HeaderMap) { + static TRACEPARENT: HeaderName = HeaderName::from_static("traceparent"); + static TRACESTATE: HeaderName = HeaderName::from_static("tracestate"); // `metadata` holds no trace-context header of its own: forwarding skips // them, so tracestate travels only here, with the trace it annotates. - if let Some(tp) = headers.get("traceparent").and_then(|v| v.to_str().ok()) { + if let Some(tp) = headers.get(&TRACEPARENT).and_then(|v| v.to_str().ok()) { if is_valid_traceparent(tp) { insert_ascii(metadata, "traceparent", tp.as_bytes()); // Every line: W3C Trace Context §3.3 lets tracestate be split // over several header lines that together form one list. - for ts in headers.get_all("tracestate") { + for ts in headers.get_all(&TRACESTATE) { append_ascii(metadata, "tracestate", ts.as_bytes()); } return; } } if let Some(tp) = new_traceparent() { - insert_ascii(metadata, "traceparent", tp.as_bytes()); + let value = tonic::metadata::AsciiMetadataValue::try_from(&tp[..]) + .expect("a traceparent is visible ASCII"); + metadata.insert( + tonic::metadata::MetadataKey::from_static("traceparent"), + value, + ); } } @@ -327,22 +376,51 @@ fn is_valid_traceparent(tp: &str) -> bool { /// Build a fresh W3C `traceparent`: `00-<16-byte trace-id>-<8-byte span-id>-01` /// (sampled). Returns `None` only if the system RNG is unavailable. -fn new_traceparent() -> Option { - let mut buf = [0u8; 24]; - getrandom::fill(&mut buf).ok()?; - let trace_id = hex(&buf[..16]); - let span_id = hex(&buf[16..]); - Some(format!("00-{trace_id}-{span_id}-01")) +fn new_traceparent() -> Option<[u8; 55]> { + let (high, low, span) = (random_id()?, random_id()?, random_id()?); + // All-zero ids are invalid (W3C Trace Context §3.2.2.3, §3.2.2.4). + let low = if high == 0 && low == 0 { 1 } else { low }; + let span = if span == 0 { 1 } else { span }; + let mut tp = *b"00-00000000000000000000000000000000-0000000000000000-01"; + write_hex(&mut tp[3..19], high); + write_hex(&mut tp[19..35], low); + write_hex(&mut tp[36..52], span); + Some(tp) +} + +/// `value` as 16 lower-case hex digits into `digits`. +fn write_hex(digits: &mut [u8], value: u64) { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + for (at, digit) in digits.iter_mut().enumerate() { + let shift = 60 - 4 * at; + *digit = DIGITS[usize::try_from((value >> shift) & 0xf).expect("a nibble")]; + } } -/// Lowercase-hex encode a byte slice. -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{b:02x}"); +/// 64 random bits for a trace id. W3C Trace Context asks for random ids, not +/// unpredictable ones (§3.2.2.3; an id is no secret), so they come from a +/// generator (wyrand) each thread seeds once from the system's, as +/// OpenTelemetry's SDKs make them, rather than a system call per request. +/// `None` only when the system cannot seed it. +fn random_id() -> Option { + thread_local! { + static STATE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } - s + STATE.with(|state| { + let seed = match state.get() { + Some(seed) => seed, + None => { + let mut bytes = [0u8; 8]; + getrandom::fill(&mut bytes).ok()?; + u64::from_ne_bytes(bytes) + } + }; + let next = seed.wrapping_add(0xa076_1d64_78bd_642f); + state.set(Some(next)); + let wide = u128::from(next) * u128::from(next ^ 0xe703_7ed1_a0b4_28db); + // The two halves of the product, folded. + Some((wide >> 64) as u64 ^ wide as u64) + }) } /// Apply a client-supplied deadline to the upstream gRPC call. diff --git a/src/transcode/metadata/tests.rs b/src/transcode/metadata/tests.rs index 4b02c51..0e24a0a 100644 --- a/src/transcode/metadata/tests.rs +++ b/src/transcode/metadata/tests.rs @@ -108,6 +108,27 @@ fn synthesized_traceparent_is_unique_per_call() { ); } +#[test] +fn synthesized_traceparents_are_valid_and_differ_across_threads() { + // Each thread draws ids from its own generator, seeded from the system's: + // two threads never repeat each other's trace, and every id is a valid + // W3C traceparent. + let draw = || { + (0..64) + .map(|_| String::from_utf8(new_traceparent().unwrap().to_vec()).unwrap()) + .collect::>() + }; + let here = draw(); + let there = std::thread::spawn(draw).join().unwrap(); + let mut all: Vec<&String> = here.iter().chain(&there).collect(); + for tp in &all { + assert!(is_valid_traceparent(tp), "{tp}"); + } + all.sort(); + all.dedup(); + assert_eq!(all.len(), 128); +} + #[test] fn grpc_timeout_parses_each_unit() { assert_eq!(parse_grpc_timeout("5S"), Some(Duration::from_secs(5))); diff --git a/src/transcode/mod.rs b/src/transcode/mod.rs index c6d2f1f..3471c11 100644 --- a/src/transcode/mod.rs +++ b/src/transcode/mod.rs @@ -14,27 +14,29 @@ pub mod codec; pub mod error; pub(crate) mod httpbody; pub mod metadata; +pub(crate) mod path; pub mod request; pub(crate) mod response; pub(crate) mod rule; mod select; +mod table; +pub use path::proto_path_to_axum; pub use select::RpcSelection; use axum::body::{Body, Bytes}; -use axum::extract::{OriginalUri, Path, RawQuery, State}; +use axum::extract::{FromRequest, FromRequestParts, OriginalUri, RawPathParams, Request, State}; use axum::http::header::{ALLOW, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{MethodFilter, MethodRouter}; -use axum::Extension; use axum::Router; use futures::{StreamExt, TryStreamExt}; use prost_reflect::{ DescriptorPool, DynamicMessage, FieldDescriptor, MessageDescriptor, MethodDescriptor, SerializeOptions, }; +use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -43,12 +45,15 @@ use tonic::metadata::MetadataMap; use crate::client_address::ClientAddress; use crate::config::AliasConfig; +use crate::guard::BoxedService; use crate::received::ReceivedRequest; use crate::service::ConnectionInfo; use crate::upstream::Upstream; use error::{ErrorDetailsPolicy, StatusDetails}; +use path::MountedPath; use response::UpstreamHeaders; use rule::RouteMethod; +use table::{Choice, PathTable, Routes}; /// How long the upstream may take to answer a call with its response headers /// when the client's `grpc-timeout` asks for no shorter deadline. @@ -66,6 +71,12 @@ pub trait TranscodeState: Clone + Send + Sync + 'static { fn into_upstream(self) -> Self::Upstream; /// Headers to forward from HTTP to gRPC metadata. fn forwarded_headers(&self) -> &[String]; + /// [`forwarded_headers`](Self::forwarded_headers) as header names, when + /// the state holds them parsed: a request then skips parsing each name. + /// `None` by default. + fn forwarded_header_names(&self) -> Option<&[HeaderName]> { + None + } /// SSE keep-alive interval (seconds) for server-streaming responses. fn sse_keep_alive_secs(&self) -> u64; } @@ -78,13 +89,18 @@ impl TranscodeState for crate::ProxyState { fn forwarded_headers(&self) -> &[String] { &self.forwarded_headers } + fn forwarded_header_names(&self) -> Option<&[HeaderName]> { + Some(&self.forwarded_names) + } fn sse_keep_alive_secs(&self) -> u64 { self.sse_keep_alive_secs } } -/// Path parameters of a matched route. -type PathParams = HashMap; +/// Path parameters of a matched route, in path order: each name with its +/// value, borrowed from the router's captures and the routes wherever the +/// binding leaves them as they are. +type PathParams<'a> = Vec<(&'a str, Cow<'a, str>)>; /// How the HTTP request body reaches the RPC's input message. #[derive(Debug, Clone)] @@ -235,34 +251,383 @@ pub fn routes(pool: &DescriptorPool, aliases: &[AliasConfig]) /// [`routes`], built as `options` describe. /// -/// A second binding for a method and path already taken (or any binding on a -/// path a `custom` `*` rule takes, which answers every method) is skipped with -/// an error in the log. +/// A binding answers the requests whose path matches its template, custom verb +/// included (`/v1/{name}:cancel`, google/api/http.proto `Verb`), and whose +/// method is its own; a GET binding answers HEAD too. A request's verb is its +/// last segment from the first unencoded `:`: a verb some binding of its path +/// binds owns the URL, answered by that verb's bindings alone; a verb none +/// binds stays part of the last variable. A URL that bindings answer, but none +/// with the request's method, is `405` with their methods in `Allow`; a path +/// that matches a template but that no binding answers is `404`. +/// +/// A second binding for a method, path and verb already taken (or any binding +/// on a path and verb a `custom` `*` rule takes, which answers every method), +/// and a template the router cannot match (text around a variable within one +/// segment, a path that does not start with `/`), are skipped with an error in +/// the log. pub fn routes_with_options( pool: &DescriptorPool, aliases: &[AliasConfig], options: &TranscodeOptions, ) -> Router { - let bindings = route_bindings(pool, aliases, &options.selection); - if bindings.is_empty() { + routes_and_choices(pool, aliases, options).0 +} + +/// The routes of [`routes_with_options`], with the [`Choices`] among their +/// bindings that the caller makes before its own layers. +pub(crate) fn routes_and_choices( + pool: &DescriptorPool, + aliases: &[AliasConfig], + options: &TranscodeOptions, +) -> (Router, Option) { + let tables = path_tables(pool, aliases, options); + if tables.is_empty() { tracing::warn!("No HTTP-annotated RPCs found in proto descriptors"); - return Router::new(); + return (Router::new(), None); + } + + let routes = Arc::new(Routes::new(tables)); + let choices = Choices { + routes: routes.clone(), + ranked: None, + }; + let mut router: Router = Router::new(); + for (table, mounted) in routes.tables.iter().enumerate() { + let routes = routes.clone(); + // Every method goes to the routes, which answer 405 themselves: the + // URL, not the path alone, decides which methods a path answers. A + // HEAD a GET binding serves still goes out without a body: axum's + // route future empties the body of every HEAD response, `any` routes + // included. + let handler = + move |State(state): State, request: Request| dispatch(routes, table, state, request); + let mut route = axum::routing::any(handler.clone()); + // A binding answering HEAD itself is the path's HEAD route, before + // the GET of another route merged at the path answers HEAD. + if mounted.binds_head() { + route = route.head(handler); + } + router = router.route(&mounted.path, route); + } + (router, Some(choices)) +} + +/// The choice of the binding a request reaches among the transcoded routes, +/// made before any layer of the routes runs. A URL no binding answers (its +/// field template, its custom verb) is no transcoded request: it goes to the +/// proxy's other routes instead. +pub(crate) struct Choices { + routes: Arc, + /// The transcoded paths among the proxy's other routes, when known. + ranked: Option>, +} + +impl Choices { + /// These choices among `others`: the router paths of the proxy's other + /// routes, each with the method it answers (`None`: every one). A table + /// answers a request a better-ranked one refused only when no other + /// route ranks before it. Ranked among none of them when an other path + /// is one the proxy cannot rank as the router does. + pub(crate) fn among(mut self, others: &[(Option, String)]) -> Self { + self.ranked = table::Ranked::new(&self.routes, others).map(Arc::new); + self + } + + /// These choices, handing a URL no binding answers to `elsewhere`: a + /// router of the proxy's other routes. + pub(crate) fn with_elsewhere(self, elsewhere: Router) -> Chooser { + Chooser(Arc::new(Choosing { + routes: self.routes, + ranked: self.ranked, + elsewhere, + })) + } + + /// The transcoded routes served past any router, calling with `state`; + /// `layered` puts the layers of the transcoded routes around them. + /// `None` without a ranking among the other routes. + pub(crate) fn direct( + &self, + state: S, + layered: impl FnOnce(BoxedService) -> BoxedService, + ) -> Option { + let ranked = self.ranked.clone()?; + let dispatcher = BoxedService::new(Dispatcher { + routes: self.routes.clone(), + state, + }); + Some(Direct { + ranking: Arc::new(Ranking { + ranked, + routes: self.routes.clone(), + }), + service: layered(dispatcher), + }) + } +} + +/// The transcoded routes served straight from the proxy's own match of a +/// request's path, past any router. +#[derive(Clone)] +pub(crate) struct Direct { + ranking: Arc, + /// The routes behind their layers. + service: BoxedService, +} + +struct Ranking { + ranked: Arc, + routes: Arc, +} + +impl Direct { + /// Whether `request` is one of the transcoded routes', whose binding + /// then rides on it: its path's best match among every route of the + /// proxy is a transcoded path, the method not another route's there, and + /// a binding answers the URL. A request for none goes to the other + /// routes, which a fallback follows, as if the transcoded ones were not + /// there. + pub(crate) fn takes(&self, request: &mut http::Request) -> bool { + let ranking = &*self.ranking; + match ranking.ranked.table(request.method(), request.uri().path()) { + Some(table) => choose(&ranking.routes, Some(&ranking.ranked), table, request), + None => false, + } + } + + /// The routes behind their layers, for a request [`takes`](Self::takes) + /// took. + pub(crate) fn service(&self) -> &BoxedService { + &self.service + } +} + +/// The transcoded routes as one service, for requests whose binding rides on +/// them. +#[derive(Clone)] +struct Dispatcher { + routes: Arc, + state: S, +} + +impl tower::Service for Dispatcher { + type Response = Response; + type Error = std::convert::Infallible; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) } + fn call(&mut self, request: Request) -> Self::Future { + let routes = self.routes.clone(); + let state = self.state.clone(); + Box::pin(async move { Ok(serve(routes, state, request).await) }) + } +} + +/// Choose among `routes` for `request`, which the router matches (or +/// matched) to the table at `table`, ranked among the proxy's other routes +/// by `ranked` when known: `false` when no binding answers it, otherwise the +/// choice rides on the request to [`dispatch`]. +fn choose( + routes: &Routes, + ranked: Option<&table::Ranked>, + table: usize, + request: &mut http::Request, +) -> bool { + let (method, path) = (request.method(), request.uri().path()); + let choice = match ranked { + Some(ranked) => match routes.choose_ranked(table, method, path, &|other| { + ranked.ranks_first(other, method, path) + }) { + // The other routes at the path answer methods too. + // The table that answered, not the one the path matched first: + // a refused binding passes the request on. + Choice::MethodNotAllowed { table, allow } => Choice::MethodNotAllowed { + table, + allow: ranked.allow(table, allow), + }, + choice => choice, + }, + None => routes.choose(table, method, path), + }; + match choice { + Choice::NotFound => false, + choice => { + request.extensions_mut().insert(Chosen(choice)); + true + } + } +} + +/// [`Choices`] with the routes a URL no binding answers goes to. One +/// reference count: the router clones its routes' layers for every request. +#[derive(Clone)] +pub(crate) struct Chooser(Arc); + +struct Choosing { + routes: Arc, + ranked: Option>, + elsewhere: Router, +} + +impl Chooser { + /// The other routes, with `fallback` answering what they do not. + pub(crate) fn with_fallback(self, fallback: BoxedService) -> Self { + Self(Arc::new(Choosing { + routes: self.0.routes.clone(), + ranked: self.0.ranked.clone(), + elsewhere: self.0.elsewhere.clone().fallback_service(fallback), + })) + } + + /// `router`, holding the transcoded routes, choosing outside its layers + /// for a router that is served on its own, merged or nested: the request + /// is past the routing then, and the router reports the route it matched + /// with the paths it is nested at in front. + pub(crate) fn layer(self, router: Router) -> Router { + router.layer(self) + } +} + +impl tower::Layer for Chooser { + type Service = ChooseRouted; + + fn layer(&self, route: S) -> ChooseRouted { + ChooseRouted { + chooser: self.clone(), + route, + } + } +} + +/// A transcoded route, with the choice among its bindings made before its +/// layers: a request no binding answers goes to the other routes. +#[derive(Clone)] +pub(crate) struct ChooseRouted { + chooser: Chooser, + route: S, +} + +impl tower::Service for ChooseRouted +where + S: tower::Service, +{ + type Response = Response; + type Error = std::convert::Infallible; + type Future = ChooseFuture; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.route.poll_ready(cx) + } + + fn call(&mut self, mut request: Request) -> Self::Future { + let choosing = &*self.chooser.0; + let extensions = request.extensions(); + let matched = extensions + .get::() + .and_then(|matched| { + let nested = extensions.get::(); + local_path(matched.as_str(), nested.map(|nested| nested.as_str())) + }) + .and_then(|path| choosing.routes.table_at(path)); + let ranked = choosing.ranked.as_deref(); + if matched.is_none_or(|matched| choose(&choosing.routes, ranked, matched, &mut request)) { + return ChooseFuture::Route { + future: self.route.call(request), + }; + } + // The other routes route it again: the route the first routing + // matched goes, so theirs is reported. Its captures stay, unread: no + // other route of the proxy takes path parameters, and an extra + // route's handler sees no extensions. Routes an application merged + // after the proxy's are not among them: the router has routed the + // request to this route and tries no other, so they are reached only + // as extra routes, or behind a proxy service's fallback. + request + .extensions_mut() + .remove::(); + ChooseFuture::Elsewhere { + future: tower::ServiceExt::oneshot(choosing.elsewhere.clone(), request), + } + } +} + +pin_project_lite::pin_project! { + /// The response future of [`ChooseRouted`]. + #[project = ChooseProjection] + pub(crate) enum ChooseFuture { + Route { #[pin] future: F }, + Elsewhere { #[pin] future: tower::util::Oneshot }, + } +} + +impl std::future::Future for ChooseFuture +where + F: std::future::Future>, +{ + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.project() { + ChooseProjection::Route { future } => future.poll(cx), + ChooseProjection::Elsewhere { future } => future.poll(cx), + } + } +} + +/// The binding chosen for a request before the layers of the routes, for +/// [`dispatch`]. +#[derive(Clone)] +struct Chosen(Choice); + +/// The router path `matched` names in the router nested at `nested`, which +/// writes the paths it is nested at in front of it. +fn local_path<'p>(matched: &'p str, nested: Option<&str>) -> Option<&'p str> { + let Some(nested) = nested else { + return Some(matched); + }; + // A prefix ending in `/` is joined to the path without its leading `/`, + // and a path of `/` alone is the prefix itself (axum's nesting). + let local = matched.strip_prefix(nested.trim_end_matches('/'))?; + Some(if local.is_empty() { "/" } else { local }) +} + +/// The tables [`routes_with_options`] mounts, one per router path, each +/// holding every binding of that path in binding order. +fn path_tables( + pool: &DescriptorPool, + aliases: &[AliasConfig], + options: &TranscodeOptions, +) -> Vec { + let bindings = route_bindings(pool, aliases, &options.selection, true); tracing::info!("Registering {} transcoded REST→gRPC routes", bindings.len()); // At most two renderers, without and with the opaque-detail extension, // each shared by every route that uses it and built only when one does. // The second is a copy of the first: the descriptor pool inside is shared. let mut status_details: [Option>; 2] = [None, None]; - // Every binding of one path goes into the same method router, in binding - // order. - let mut paths: Vec = Vec::new(); - let mut path_index: HashMap = HashMap::new(); + // The router tells paths apart by shape only, so every binding of one + // shape goes into one table, whatever its capture names. + let mut tables: Vec = Vec::new(); + let mut by_shape: HashMap = HashMap::new(); for mut binding in bindings { let policy = &options.error_details; - if policy.enabled_for(&binding.axum_path) { - let opaque = policy.opaque_for(&binding.axum_path); + let route = binding.mount.display(); + if policy.enabled_for(&route) { + let opaque = policy.opaque_for(&route); let slot = usize::from(opaque); if status_details[slot].is_none() { let base = status_details @@ -277,163 +642,405 @@ pub fn routes_with_options( } binding.entry.ndjson_envelope = options.ndjson_envelope; binding.entry.denied_headers = options.denied_response_headers.clone(); - let index = match path_index.get(&binding.axum_path) { - Some(&index) => index, + match by_shape.get(&binding.mount.shape) { + Some(&index) => tables[index].add(binding.mount, binding.entry), None => { - path_index.insert(binding.axum_path.clone(), paths.len()); - paths.push(PathRoutes { - path: binding.axum_path, - methods: Vec::new(), - }); - paths.len() - 1 + by_shape.insert(binding.mount.shape.clone(), tables.len()); + tables.push(PathTable::new(binding.mount, binding.entry)); } - }; - paths[index].add(Arc::new(binding.entry)); + } } - let mut router: Router = Router::new(); - for path in &paths { - router = router.route(&path.path, path.method_router()); - } - router -} - -/// The axum handler serving the route entry `$entry`, for the state type `S` -/// in scope. A macro because the closure's handler type cannot be named. -macro_rules! endpoint { - ($entry:expr) => {{ - let entry: Arc = $entry; - move |state: State, - method: Method, - OriginalUri(uri): OriginalUri, - headers: HeaderMap, - path_params: Path, - raw_query: RawQuery, - connection: Option>, - address: Option>, - body: Bytes| { - let client = Client { - headers, - origin: Origin { - connection: connection.map(|Extension(connection)| connection), - address: address.map(|Extension(address)| address), - }, - line: RequestLine { method, uri }, - }; - handle(state, client, path_params, raw_query, body, entry) + retain_mountable( + &mut tables, + |table| table.path.as_str(), + |path, error| { + tracing::error!( + path = %path, + %error, + "transcoded route conflicts with another; skipping its bindings" + ); + }, + ); + tables +} + +/// Keep the items whose router path a router holding the ones kept before +/// accepts, in order; `refused` hears of each left out. axum would panic on +/// such a path, and matchit is the router axum matches with. +fn retain_mountable( + items: &mut Vec, + path: impl Fn(&T) -> &str, + mut refused: impl FnMut(&str, matchit::InsertError), +) { + let mut router = matchit::Router::new(); + items.retain(|item| match router.insert(path(item), ()) { + Ok(()) => true, + Err(error) => { + refused(path(item), error); + false } - }}; + }); } -/// The bindings mounted at one axum path. -struct PathRoutes { - path: String, - methods: Vec>, +/// The shapes of the router paths [`routes_with_options`] mounts for +/// `selection`: what the OpenAPI document may promise. +pub(crate) fn mounted_shapes( + pool: &DescriptorPool, + aliases: &[AliasConfig], + selection: &RpcSelection, +) -> std::collections::HashSet { + // One path per shape, the first binding's, as the tables are built. + let mut seen = std::collections::HashSet::new(); + let mut paths: Vec = route_bindings(pool, aliases, selection, false) + .into_iter() + .filter_map(|binding| { + seen.insert(binding.mount.shape.clone()) + .then_some(binding.mount) + }) + .collect(); + retain_mountable(&mut paths, |mount| mount.axum.as_str(), |_, _| {}); + paths.into_iter().map(|mount| mount.shape).collect() } -impl PathRoutes { - /// Add `entry` unless its method is already answered on this path. - fn add(&mut self, entry: Arc) { - let taken = self.methods.iter().any(|existing| { - existing.http_method == entry.http_method - || existing.http_method == RouteMethod::Any - || entry.http_method == RouteMethod::Any - }); - if taken { - tracing::error!( - method = entry.http_method.as_str(), - path = %self.path, - rpc = %entry.grpc_path, - "HTTP method and path already bound to another RPC; skipping this binding" - ); - return; +/// Serve `request`, which the router matched to the table at `matched`: +/// choose the binding before anything is extracted, so a request no binding +/// answers gets its 404 or 405 without its body being read. +async fn dispatch( + routes: Arc, + matched: usize, + state: S, + request: Request, +) -> Response { + let (mut parts, body) = request.into_parts(); + let choice = match parts.extensions.remove::() { + Some(Chosen(choice)) => choice, + None => routes.choose(matched, &parts.method, parts.uri.path()), + }; + let (table, index) = match choice { + Choice::Route { table, index } => (table, index), + Choice::MethodNotAllowed { allow, .. } => { + return (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response() + } + // Only routes served without a `Chooser` get here. + Choice::NotFound => return StatusCode::NOT_FOUND.into_response(), + }; + // The router's captures, decoded, shared with it rather than copied. + let captured = match RawPathParams::from_request_parts(&mut parts, &state).await { + Ok(captured) => captured, + Err(rejection) => return rejection.into_response(), + }; + let mut path_params: PathParams<'_> = captured + .iter() + .map(|(name, value)| (name, Cow::Borrowed(value))) + .collect(); + let multi_segment = routes.tables[table].multi_segment(index); + // The path the router matched, kept past the parts going on with the + // body when the captures are read from it. + let local; + if table != matched || !multi_segment.is_empty() { + // Another path the request matches answers it (a verb bound there, or + // the only bindings without a verb), or a variable over several + // segments keeps `%2F` the router decoded: the captures come from the + // request's path, the router's prefix parameters stay. + local = parts.uri.clone(); + // The matched path's captures are the router's last parameters, after + // those of the prefixes it is nested under, which may share their + // names: they go by position, the prefixes' stay. + let prefixes = path_params + .len() + .checked_sub(routes.tables[matched].captures.len()) + .expect("the router captures every variable of the path it matched"); + path_params.truncate(prefixes); + if let Err(rejection) = capture( + &routes, + table, + multi_segment, + local.path(), + &mut path_params, + ) { + return rejection.into_response(routes.tables[table].entry(index)); } - self.methods.push(entry); } + // The target as received, before a router the proxy is nested in strips + // its prefix; the router records it on every request it routes. + let uri = match parts.extensions.remove::() { + Some(OriginalUri(uri)) => uri, + None => parts.uri.clone(), + }; + let begun = begin( + &routes, + (table, index), + &state, + (parts, body), + path_params, + uri, + ) + .await; + serve_begun(begun, routes, table, index).await +} - /// One method router for every binding of the path. Methods axum routes by - /// itself are registered directly; any other token (a `custom` rule such - /// as `PROPFIND`) is dispatched by a fallback that answers `405` with the - /// full `Allow` list (RFC 9110 §15.5.6) for a method nobody binds. - fn method_router(&self) -> MethodRouter { - let mut router = MethodRouter::new(); - let mut extension: Vec<(Method, Arc)> = Vec::new(); - let mut allow: Vec<&str> = Vec::new(); - for entry in &self.methods { - match &entry.http_method { - // `add` keeps a `*` binding alone on its path. - RouteMethod::Any => return axum::routing::any(endpoint!(entry.clone())), - RouteMethod::One(method) => { - allow.push(method.as_str()); - match MethodFilter::try_from(method.clone()) { - Ok(filter) => router = router.on(filter, endpoint!(entry.clone())), - Err(_) => extension.push((method.clone(), entry.clone())), - } +/// Serve `request`, which the proxy matched to a transcoded binding itself, +/// with no router: the choice rides on it. +async fn serve(routes: Arc, state: S, request: Request) -> Response { + let (mut parts, body) = request.into_parts(); + let head = parts.method == Method::HEAD; + let connect = parts.method == Method::CONNECT; + let choice = match parts.extensions.remove::() { + Some(Chosen(choice)) => choice, + None => Choice::NotFound, + }; + let mut response = match choice { + Choice::Route { table, index } => { + // The path the proxy matched, what the captures are read from. + let local = parts.uri.clone(); + // The target as received, before a router the proxy is nested in + // strips its prefix. + let uri = match parts.extensions.remove::() { + Some(OriginalUri(uri)) => uri, + None => local.clone(), + }; + let multi_segment = routes.tables[table].multi_segment(index); + let mut path_params = Vec::with_capacity(routes.tables[table].captures.len()); + match capture( + &routes, + table, + multi_segment, + local.path(), + &mut path_params, + ) { + Ok(()) => { + let begun = begin( + &routes, + (table, index), + &state, + (parts, body), + path_params, + uri, + ) + .await; + serve_begun(begun, routes, table, index).await } + Err(rejection) => rejection.into_response(routes.tables[table].entry(index)), } } - if extension.is_empty() { - return router; + Choice::MethodNotAllowed { allow, .. } => { + (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response() } - // A GET route answers HEAD too. - if allow.contains(&"GET") && !allow.contains(&"HEAD") { - allow.push("HEAD"); + Choice::NotFound => StatusCode::NOT_FOUND.into_response(), + }; + let status = response.status(); + // A 2xx answer to CONNECT has no content and no Content-Length (RFC 9110 + // §9.3.6, §8.6), as a router's route answers it too. + if connect && status.is_success() { + response.headers_mut().remove(http::header::CONTENT_LENGTH); + *response.body_mut() = axum::body::Body::empty(); + return response; + } + // As a router's route answers: the length of a body whose size is known, + // and none sent for a HEAD. Not on a 204, which has no Content-Length, nor + // on a 304, whose length could only describe the selected representation + // (RFC 9110 §8.6), never the empty body sent. + if status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED + && !response + .headers() + .contains_key(http::header::CONTENT_LENGTH) + { + if let Some(length) = http_body::Body::size_hint(response.body()).exact() { + response + .headers_mut() + .insert(http::header::CONTENT_LENGTH, HeaderValue::from(length)); } - let allow = HeaderValue::from_str(&allow.join(", ")) - .expect("method tokens are valid header value characters"); - let extension: Arc<[(Method, Arc)]> = extension.into(); - // The method decides before anything is extracted, so a request no - // binding answers gets its 405 without its body being read. - router.fallback( - move |State(state): State, request: axum::extract::Request| async move { - match extension - .iter() - .find(|(bound, _)| bound == request.method()) - { - Some((_, entry)) => { - axum::handler::Handler::call(endpoint!(entry.clone()), request, state).await - } - None => (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response(), - } - }, - ) + } + if head { + *response.body_mut() = axum::body::Body::empty(); + } + response +} + +/// How far [`begin`] got: the upstream call to make, or the answer already. +enum Begun { + Call { + call: Call, + sse: bool, + keep_alive_secs: u64, + }, + Answered(Response), +} + +/// Bind the request of `parts` and `body` to the binding at `binding` (table, +/// index) with its path parameters `path_params`, its target as received +/// `uri`: read its body and map it onto the RPC. +async fn begin( + routes: &Routes, + (table, index): (usize, usize), + state: &S, + (mut parts, body): (http::request::Parts, axum::body::Body), + mut path_params: PathParams<'_>, + uri: Uri, +) -> Begun { + routes.tables[table].bind_params(index, &mut path_params); + // Taken rather than copied: reading the body needs only its limit, which + // stays in the extensions. + let client = Client { + headers: std::mem::take(&mut parts.headers), + origin: Origin { + connection: parts.extensions.remove::(), + address: parts.extensions.remove::(), + }, + line: RequestLine { + method: std::mem::replace(&mut parts.method, Method::GET), + uri, + }, + }; + let body = match Bytes::from_request(Request::from_parts(parts, body), state).await { + Ok(body) => body, + Err(rejection) => return Begun::Answered(rejection.into_response()), + }; + let entry = routes.tables[table].entry(index); + match start(state.clone(), client, &path_params, body, entry) { + Ok((call, sse, keep_alive_secs)) => Begun::Call { + call, + sse, + keep_alive_secs, + }, + Err(rejection) => Begun::Answered(rejection.into_response(entry)), + } +} + +/// Make the call [`begin`] got to, the parameters bound: the routes go on +/// with it. +async fn serve_begun( + begun: Begun, + routes: Arc, + table: usize, + index: usize, +) -> Response { + let (call, sse, keep_alive_secs) = match begun { + Begun::Call { + call, + sse, + keep_alive_secs, + } => (call, sse, keep_alive_secs), + Begun::Answered(response) => return response, + }; + let entry = RouteRef { + routes, + table, + index, + }; + if entry.streaming { + streaming_call(call, entry, sse, keep_alive_secs).await + } else { + unary_call(call, &entry).await + } +} + +/// Add the captures of the table at `table` matching `path` to `params`, +/// percent-decoded as the router decodes its own except at the positions of +/// `multi_segment`, which keep `%2F`; borrowed from `path` where nothing is +/// decoded. +fn capture<'p>( + routes: &'p Routes, + table: usize, + multi_segment: &[usize], + path: &'p str, + params: &mut PathParams<'p>, +) -> Result<(), Unmappable> { + let found = routes + .params_of(table, path) + .expect("the chosen table was found by matching this path"); + for (position, (name, raw)) in found.iter().enumerate() { + let decoded: Cow<'p, [u8]> = if multi_segment.contains(&position) { + path::decode_multi_segment(raw) + } else { + percent_encoding::percent_decode_str(raw).into() + }; + let not_utf8 = || Unmappable(format!("path parameter {name} is not valid UTF-8")); + let value = match decoded { + Cow::Borrowed(bytes) => { + Cow::Borrowed(std::str::from_utf8(bytes).map_err(|_| not_utf8())?) + } + Cow::Owned(bytes) => Cow::Owned(String::from_utf8(bytes).map_err(|_| not_utf8())?), + }; + params.push((name, value)); + } + Ok(()) +} + +/// The binding serving a request, held through the routes: they are already +/// shared with the request, so naming the binding takes no refcount of its +/// own. +#[derive(Clone)] +struct RouteRef { + routes: Arc, + table: usize, + index: usize, +} + +impl std::ops::Deref for RouteRef { + type Target = RouteEntry; + + fn deref(&self) -> &RouteEntry { + self.routes.tables[self.table].entry(self.index) } } -/// One transcode route to mount: the RPC entry that serves it and the axum path -/// to register it at. +/// One transcode route to mount: the RPC entry that serves it and where it is +/// mounted. struct RouteBinding { entry: RouteEntry, - axum_path: String, + mount: MountedPath, +} + +impl RouteBinding { + fn claim(&self) -> table::Claim<'_> { + table::Claim { + method: &self.entry.http_method, + verb: self.mount.verb.as_deref(), + template: self.mount.last_template.as_deref(), + literals: &self.mount.literals, + } + } } /// The single source of truth for what [`routes`] mounts: every binding of -/// every unary and server-streaming RPC, plus its config aliases. Both -/// [`routes`] (to build handlers) and [`route_paths`] (to enumerate paths for -/// collision checks) consume this, so the mounted set and the enumerated set -/// cannot drift apart. +/// every unary and server-streaming RPC, plus its config aliases, less the +/// templates the router cannot match. Both [`routes`] (to build handlers) and +/// [`route_paths`] (to enumerate paths for collision checks) consume this, so +/// the mounted set and the enumerated set cannot drift apart. A template left +/// out is logged when `report` is set. fn route_bindings( pool: &DescriptorPool, aliases: &[AliasConfig], selection: &RpcSelection, + report: bool, ) -> Vec { let mut bindings = Vec::new(); + let mut push = |mount: MountedPath, entry: RouteEntry| match mount.routable() { + Ok(()) => bindings.push(RouteBinding { entry, mount }), + Err(_) if !report => {} + Err(error) => tracing::error!( + path = %mount.display(), + rpc = %entry.grpc_path, + %error, + "google.api.http path template cannot be routed; skipping this binding" + ), + }; for entry in extract_routes(pool, selection) { for alias in aliases { if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) { if alias.from.ends_with("/{path}") { let prefix = alias.from.trim_end_matches("/{path}"); - bindings.push(RouteBinding { - axum_path: proto_path_to_axum(&format!("{prefix}{suffix}")), - entry: entry.clone(), - }); + push( + MountedPath::new(&format!("{prefix}{suffix}")), + entry.clone(), + ); } } } - bindings.push(RouteBinding { - axum_path: proto_path_to_axum(&entry.http_path), - entry, - }); + push(MountedPath::new(&entry.http_path), entry); } bindings } @@ -447,17 +1054,115 @@ fn route_bindings( /// (e.g. a forward-auth endpoint). /// /// Each entry is `(method, path)` where `method` is the uppercase HTTP token, -/// or `*` for a `custom` rule that answers every method, so callers can -/// distinguish same-path/different-method routes from real conflicts. +/// or `*` for a route that answers every method, so callers can distinguish +/// same-path/different-method routes from real conflicts. The bindings of one +/// path are listed as the route they share: `*` once when a `custom` `*` rule +/// or a custom verb after a variable is among them (the transcoded routes +/// match the verb, and answer a bound verb's other methods with 405), else +/// each method once. Two bindings that cannot both serve (one method or a `*` +/// rule, one verb) are listed both, so the caller sees the collision. pub fn route_paths( pool: &DescriptorPool, aliases: &[AliasConfig], selection: &RpcSelection, ) -> Vec<(String, String)> { - route_bindings(pool, aliases, selection) + let mut paths = Vec::new(); + for bindings in &shape_bindings(pool, aliases, selection) { + // The route the shape shares, under the spelling it is mounted with: + // that of its first binding, whatever names the others capture under. + let path = bindings[0].mount.axum.as_str(); + let listed = |method: &str| (method.to_owned(), path.to_owned()); + // A path with a verb after its variable answers every method: one + // whose verb is bound only for others is told so with 405. + if bindings + .iter() + .any(|b| b.entry.http_method == RouteMethod::Any || b.mount.verb.is_some()) + { + paths.push(listed("*")); + } else { + let mut methods: Vec<&str> = Vec::new(); + for binding in bindings { + let method = binding.entry.http_method.as_str(); + if !methods.contains(&method) { + methods.push(method); + paths.push(listed(method)); + } + } + } + for (at, binding) in bindings.iter().enumerate() { + let clashes = bindings[..at] + .iter() + .any(|earlier| table::clash(&earlier.claim(), &binding.claim())); + if clashes { + paths.push(listed(binding.entry.http_method.as_str())); + } + } + } + paths +} + +/// The bindings of each route shape the router mounts, in first-binding +/// order: a shape it refuses beside the ones before it is skipped, as the +/// tables are, and answers nothing. The routes themselves report a template +/// left out. +fn shape_bindings( + pool: &DescriptorPool, + aliases: &[AliasConfig], + selection: &RpcSelection, +) -> Vec> { + let mut shapes: Vec> = Vec::new(); + let mut by_shape: HashMap = HashMap::new(); + for binding in route_bindings(pool, aliases, selection, false) { + match by_shape.get(&binding.mount.shape) { + Some(&index) => shapes[index].push(binding), + None => { + by_shape.insert(binding.mount.shape.clone(), shapes.len()); + shapes.push(vec![binding]); + } + } + } + retain_mountable( + &mut shapes, + |bindings| bindings[0].mount.axum.as_str(), + |_, _| {}, + ); + shapes +} + +/// The methods the transcoded bindings for one pool, aliases and selection +/// answer: what [`route_paths`] lists under `*` still names methods a route +/// answers, or every one for a `custom` `*` rule. +pub(crate) struct BoundMethods { + /// The methods the bindings name, each once. + pub(crate) methods: Vec, + /// Whether a `custom` `*` rule answers every method. + pub(crate) every: bool, +} + +/// The [`BoundMethods`] of the transcoded bindings for this pool, aliases +/// and selection that the router mounts: a skipped route answers none. +pub(crate) fn bound_methods( + pool: &DescriptorPool, + aliases: &[AliasConfig], + selection: &RpcSelection, +) -> BoundMethods { + let mut bound = BoundMethods { + methods: Vec::new(), + every: false, + }; + for binding in shape_bindings(pool, aliases, selection) .into_iter() - .map(|b| (b.entry.http_method.as_str().to_string(), b.axum_path)) - .collect() + .flatten() + { + match binding.entry.http_method { + RouteMethod::One(method) if !bound.methods.contains(&method) => { + bound.methods.push(method); + } + RouteMethod::One(_) => {} + RouteMethod::Any => bound.every = true, + } + } + bound } /// JSON serialization options shared by the unary and streaming response paths, @@ -601,44 +1306,44 @@ struct Origin { address: Option, } -/// Serve one request on a transcoded route. -async fn handle( - State(proxy_state): State, +/// The upstream call one request on a transcoded route makes, with whether a +/// streamed answer goes out as SSE and its keep-alive; or why the request +/// cannot be mapped onto the RPC. +fn start( + proxy_state: S, client: Client, - Path(path_params): Path, - RawQuery(raw_query): RawQuery, + path_params: &[(&str, Cow<'_, str>)], body: Bytes, - entry: Arc, -) -> Response { + entry: &RouteEntry, +) -> Result<(Call, bool, u64), Unmappable> { let keep_alive_secs = proxy_state.sse_keep_alive_secs(); let Client { headers, origin, line, } = client; - let prepared = line.received(&entry).and_then(|received| { - let mut call = prepare( - proxy_state, - &headers, - origin, - &path_params, - raw_query.as_deref(), - body, - &entry, - )?; + // The query is read where it was received, before the request line is + // handed on. + let prepared = prepare( + proxy_state, + &headers, + origin, + path_params, + line.uri.query(), + body, + entry, + ) + .and_then(|mut call| { // Next to the extensions `prepare` sets, for an upstream in process. - call.request.extensions_mut().insert(received); + call.request.extensions_mut().insert(line.received(entry)?); Ok(call) }); - let call = match prepared { - Ok(call) => call, - Err(rejection) => return rejection.into_response(&entry), - }; - if entry.streaming { - streaming_call(call, entry, wants_sse(&headers), keep_alive_secs).await - } else { - unary_call(call, &entry).await - } + let call = prepared?; + Ok(( + call, + entry.streaming && wants_sse(&headers), + keep_alive_secs, + )) } /// Why a request ends before the upstream is called: it cannot be mapped onto @@ -702,7 +1407,7 @@ fn prepare( proxy_state: S, headers: &HeaderMap, origin: Origin, - path_params: &PathParams, + path_params: &[(&str, Cow<'_, str>)], raw_query: Option<&str>, body: Bytes, entry: &RouteEntry, @@ -714,15 +1419,12 @@ fn prepare( // A request the proxy resolved carries the client-address headers its // forwarding policy wrote; one routed here without that resolution // carries only what its client asserted, which is never forwarded. - let request_metadata = match address { - Some(_) => { - metadata::rewritten_headers_to_grpc_metadata(headers, proxy_state.forwarded_headers()) - } - None => { - metadata::try_http_headers_to_grpc_metadata(headers, proxy_state.forwarded_headers()) - } - } - .map_err(|e| Unmappable(e.to_string()))?; + let forwarded = match proxy_state.forwarded_header_names() { + Some(names) => metadata::Forwarded::Named(names), + None => metadata::Forwarded::Listed(proxy_state.forwarded_headers()), + }; + let request_metadata = metadata::forwarded_metadata(headers, forwarded, address.is_some()) + .map_err(|e| Unmappable(e.to_string()))?; let message = decode_request(entry, headers, path_params, raw_query, body).map_err(Unmappable)?; let mut request = tonic::Request::new(message); @@ -905,7 +1607,7 @@ fn upstream_error(mut status: tonic::Status, entry: &RouteEntry) -> Response { /// sent and are not forwarded. async fn streaming_call( call: Call, - entry: Arc, + entry: RouteRef, use_sse: bool, keep_alive_secs: u64, ) -> Response { @@ -946,7 +1648,7 @@ async fn streaming_call( /// and the client sees a truncated transfer instead of a clean end. async fn http_body_stream( mut stream: tonic::Streaming, - entry: Arc, + entry: RouteRef, initial: MetadataMap, ) -> Response { let first = match stream.message().await { @@ -1118,7 +1820,7 @@ static NO_PARSED_BODY: request::BodyMapping = request::BodyMapping::None; fn decode_request( entry: &RouteEntry, headers: &HeaderMap, - path_params: &PathParams, + path_params: &[(&str, Cow<'_, str>)], raw_query: Option<&str>, body_bytes: Bytes, ) -> Result { @@ -1139,13 +1841,8 @@ fn decode_request( RequestBody::RawField { mapping, .. } => (mapping, request::Body::Absent, raw_query), }; - let mut message = request::build_request_message( - &entry.method.input(), - mapping, - body, - path_params, - raw_query, - )?; + let mut message = + request::build_message(&entry.method.input(), mapping, body, path_params, raw_query)?; match &entry.request_body { RequestBody::Parsed(_) => {} RequestBody::RawRoot => { @@ -1271,118 +1968,5 @@ fn response_shape(output: &MessageDescriptor, response_body: Option) -> } } -/// Convert a `google.api.http` path template to axum 0.8 path syntax. -/// -/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain -/// single-segment params pass through verbatim. Only field-path templates and -/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}` -/// and rejects any segment starting with `:`): -/// - `{name=*}` (single segment) -> `{name}` -/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all) -/// - bare `*` segment -> `{wildcardN}` -/// - bare `**` segment -> `{*wildcardN}` (axum catch-all) -pub fn proto_path_to_axum(path: &str) -> String { - let mut out = String::with_capacity(path.len()); - - let segments = split_top_level(path); - let last = segments.len().saturating_sub(1); - for (idx, segment) in segments.iter().enumerate() { - if idx > 0 { - out.push('/'); - } - out.push_str(&convert_segment(segment, idx, idx == last)); - } - - out -} - -/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span. -/// -/// google.api.http field templates can embed slashes inside a single capture -/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive -/// `str::split('/')` would fracture the brace span into invalid fragments. -/// Tracking brace depth keeps each capture intact. -fn split_top_level(path: &str) -> Vec<&str> { - let mut segments = Vec::new(); - let mut depth = 0usize; - let mut start = 0usize; - - for (i, ch) in path.char_indices() { - match ch { - '{' => depth += 1, - // Decrement only on a matched brace; a stray `}` (malformed input) - // is treated as a literal rather than driving depth negative. - '}' if depth > 0 => depth -= 1, - '/' if depth == 0 => { - segments.push(&path[start..i]); - start = i + 1; - } - _ => {} - } - } - segments.push(&path[start..]); - segments -} - -/// Convert a single top-level path segment from proto template to axum 0.8 form. -/// -/// `is_last` indicates the terminal segment: axum permits a catch-all capture -/// (`{*name}`) only there, so catch-alls in any other position must degrade. -fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String { - if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { - // Brace capture, possibly with a `name=template` field path. - if let Some((name, template)) = inner.split_once('=') { - return match template { - // Single-segment field path collapses to a plain capture. - "*" => format!("{{{name}}}"), - // Multi-segment catch-all maps to axum's `{*name}` (terminal only). - "**" => catch_all(name, is_last), - // Templates with interspersed literals (`{name=shelves/*/books/*}`) - // have no faithful axum form: axum cannot bind literal segments - // into one capture. Collapse to a catch-all so routing stays - // deterministic and the field still binds to the matched tail, - // and warn so the limitation surfaces instead of mis-routing. - _ => { - tracing::warn!( - template = %inner, - "google.api.http multi-segment field template is not fully \ - supported; routing it as a catch-all capture" - ); - catch_all(name, is_last) - } - }; - } - // Plain `{name}` is already valid axum 0.8 syntax. - return format!("{{{inner}}}"); - } - - // Bare wildcards: name them by position so multiple wildcards never collide. - match segment { - "**" => catch_all(&format!("wildcard{idx}"), is_last), - "*" => format!("{{wildcard{idx}}}"), - literal => literal.to_string(), - } -} - -/// Emit an axum catch-all `{*name}` when `is_last`, else degrade to a -/// single-segment `{name}` capture. -/// -/// axum accepts a catch-all only in the final path segment; a mid-path -/// `{*name}` is rejected at `Router::route()`. A non-terminal catch-all comes -/// from a malformed or unsupported google.api.http template, so we degrade -/// (capturing one segment) and warn rather than panic the whole router. -fn catch_all(name: &str, is_last: bool) -> String { - if is_last { - format!("{{*{name}}}") - } else { - tracing::warn!( - capture = %name, - "catch-all in a non-terminal path segment is unrepresentable in axum; \ - degrading to a single-segment capture" - ); - format!("{{{name}}}") - } -} - #[cfg(test)] mod tests; diff --git a/src/transcode/path.rs b/src/transcode/path.rs new file mode 100644 index 0000000..e1636a1 --- /dev/null +++ b/src/transcode/path.rs @@ -0,0 +1,474 @@ +//! `google.api.http` path templates in the form the router mounts them. +//! +//! The template grammar (google/api/http.proto) is +//! `Template = "/" Segments [ Verb ]` with `Verb = ":" LITERAL`. axum (matchit) +//! matches a literal after a variable in no segment, so a verb that follows a +//! variable or a wildcard is split off here and matched by the transcoded +//! router itself; a verb after a literal stays part of that literal. + +use std::borrow::Cow; + +/// Where one binding is mounted. +#[derive(Debug, Clone)] +pub(crate) struct MountedPath { + /// The axum path (`/v1/{name}`), without a verb that follows a variable. + pub(crate) axum: String, + /// `axum` with every capture unnamed: the bindings that share it share one + /// route, since the router tells their paths apart by shape only. + pub(crate) shape: String, + /// The capture names of `axum`, in path order. + pub(crate) captures: Vec, + /// For each capture, whether it stands for a bare `*` or `**`: the router + /// needs a name for it, but it binds no field (google/api/http.proto). + pub(crate) unbound: Vec, + /// For each capture, whether it is (part of) a variable matching several + /// segments, whose value keeps `%2F` encoded (google/api/http.proto). + pub(crate) multi_segment: Vec, + /// The custom verb after the last segment's variable, `:` included. + pub(crate) verb: Option, + /// Whether the last capture may be empty: a `**` matches zero or more + /// segments (google/api/http.proto), any other variable at least one. + pub(crate) empty_last: bool, + /// The field template of the last capture when the router cannot hold it + /// (`{name=shelves/*/books/*}` mounted as a catch-all): its segments, which + /// the matched value must follow. + pub(crate) last_template: Option>, + /// The index of the last segment, the one the last capture starts at. + pub(crate) last_segment: usize, + /// Why the router cannot hold this template faithfully, when it cannot. + pub(crate) unsupported: Option, + /// The fields bound to a multi-segment template before the last segment, + /// spelled out in the path: each is put back together from its parts. + pub(crate) composites: Vec, + /// The literals of those templates that hold a percent-escape, as + /// written, with the index of their path segment. The router compares a + /// literal byte for byte, while `%3a` and `%3A` are one octet (RFC 3986 + /// §6.2.2.1), so such a segment is mounted as a capture and compared by + /// the transcoded routes. + pub(crate) literals: Vec<(usize, String)>, +} + +/// A field whose template (`{parent=publishers/*}`) the path spells out +/// segment by segment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Composite { + pub(crate) field: String, + pub(crate) parts: Vec, +} + +/// One segment of a [`Composite`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Part { + /// A literal of the template without a percent-escape. + Literal(String), + /// A `*` of the template, or a literal with a percent-escape: the capture + /// at this position of the path. + Capture(usize), +} + +impl MountedPath { + /// Mount `template`, a `google.api.http` path template. + pub(crate) fn new(template: &str) -> Self { + let (base, verb) = split_verb(template); + let segments = split_top_level(base); + let last = segments.len() - 1; + let mut axum = String::with_capacity(base.len()); + let mut shape = String::with_capacity(base.len()); + let mut captures = Vec::new(); + let mut unbound = Vec::new(); + let mut multi_segment = Vec::new(); + let mut composites = Vec::new(); + let mut literals = Vec::new(); + let mut unsupported = None; + // Segments written so far: the last capture starts at this index. + let mut written = 0; + let empty_last = ends_in_double_wildcard(segments[last]); + let last_template = field_template(segments[last]) + .filter(|template| *template != "*" && *template != "**") + .map(|template| { + split_top_level(template) + .into_iter() + .map(str::to_owned) + .collect() + }); + let mut last_segment = 0; + for (idx, segment) in segments.iter().enumerate() { + if idx == last { + last_segment = written; + } + let mut push_segment = |axum: &mut String, shape: &mut String| { + if written > 0 { + axum.push('/'); + shape.push('/'); + } + written += 1; + }; + // A multi-segment template before the last segment: the router + // holds no capture across segments there, so its segments are + // written out; `**` among them has no such form. + if field_template(segment).is_some_and(|template| template.contains(['{', '}'])) { + unsupported = Some(format!( + "`{segment}` nests a variable in a field template, which holds none" + )); + } + // `**` must be the last part of the path (google/api/http.proto); + // anywhere else it would also make matching try every split. + if idx == last + && field_template(segment) + .is_some_and(|template| template.split('/').rev().skip(1).any(|p| p == "**")) + { + unsupported = Some(format!( + "`{segment}` puts `**` before another segment; it must be the last one" + )); + } + let inner = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')); + if let Some((field, template)) = inner + .and_then(|inner| inner.split_once('=')) + .filter(|(_, template)| idx != last && *template != "*") + { + if template.split('/').any(|part| part == "**") { + unsupported = Some(format!( + "`**` in `{segment}` before the last segment matches any number of \ + segments, which the router cannot hold there" + )); + } else { + // A template of one segment (`{name=foo}`) is a variable of + // one segment, decoded in full. + let multi = template.contains('/'); + let mut parts = Vec::new(); + for (k, part) in template.split('/').enumerate() { + push_segment(&mut axum, &mut shape); + // An escaped literal is a capture to the router, so its + // case does not matter (RFC 3986 §6.2.2.1); like any + // capture, it cannot share its position with a + // catch-all, which the router then refuses. + let escaped = part.contains('%'); + if part == "*" || escaped { + let name = format!("{field}.{k}"); + axum.push('{'); + axum.push_str(&name); + axum.push('}'); + shape.push_str("{}"); + captures.push(name); + unbound.push(true); + multi_segment.push(multi); + } else { + axum.push_str(part); + shape.push_str(part); + } + if escaped { + // The index of this segment: one per `/` before it. + let at = axum.bytes().filter(|&b| b == b'/').count(); + literals.push((at, part.to_owned())); + } + // An escaped literal is taken from the request, decoded + // as the variable is: a kept `%2F` keeps its case there. + parts.push(if part == "*" || escaped { + Part::Capture(captures.len() - 1) + } else { + Part::Literal(part.to_owned()) + }); + } + composites.push(Composite { + field: field.to_owned(), + parts, + }); + continue; + } + } else if idx != last && is_double_wildcard(segment) { + unsupported = Some(format!( + "`{segment}` before the last segment matches any number of segments, \ + which the router cannot hold there" + )); + } + push_segment(&mut axum, &mut shape); + match convert_segment(segment, idx, idx == last) { + Segment::Literal(literal) => { + axum.push_str(literal); + shape.push_str(literal); + } + Segment::Capture { name, catch_all } => { + let open = if catch_all { "{*" } else { "{" }; + axum.push_str(open); + axum.push_str(&name); + axum.push('}'); + shape.push_str(open); + shape.push('}'); + captures.push(name); + unbound.push(*segment == "*" || *segment == "**"); + // A catch-all may hold a template of one segment; what + // counts is the template. + multi_segment.push(catch_all && spans_segments(segment)); + } + } + } + Self { + axum, + shape, + captures, + unbound, + multi_segment, + verb: verb.map(str::to_owned), + empty_last, + last_template, + last_segment, + unsupported, + composites, + literals, + } + } + + /// Whether the router can serve this template as written: a template it + /// cannot hold faithfully, or a path axum would refuse, is not served. + pub(crate) fn routable(&self) -> Result<(), String> { + match &self.unsupported { + Some(reason) => Err(reason.clone()), + None => mountable(&self.axum), + } + } + + /// The route as written, verb included: what route policies match and + /// what the log names. + pub(crate) fn display(&self) -> Cow<'_, str> { + match &self.verb { + None => Cow::Borrowed(&self.axum), + Some(verb) => Cow::Owned(format!("{}{verb}", self.axum)), + } + } +} + +/// Convert a `google.api.http` path template to axum 0.8 path syntax. +/// +/// The proto `{param}` form IS axum 0.8's native capture syntax, so plain +/// single-segment params pass through verbatim. Only field-path templates and +/// bare wildcards need rewriting (axum 0.7 used `:param`; 0.8 uses `{param}` +/// and rejects any segment starting with `:`): +/// - `{name=*}` (single segment) -> `{name}` +/// - `{name=**}` (multi-segment) -> `{*name}` (axum catch-all) +/// - bare `*` segment -> `{N}`, N its segment index +/// - bare `**` segment -> `{*N}` (axum catch-all) +/// +/// A custom verb after a variable or wildcard (`{name}:cancel`) is not part of +/// the result: axum cannot match it, so the transcoded router checks it +/// itself. A verb after a literal (`/v1/nodes:batch`) stays in that literal. +pub fn proto_path_to_axum(path: &str) -> String { + MountedPath::new(path).axum +} + +/// Whether axum can register `axum_path` on its own: what `Router::route` +/// checks before it inserts, then the insertion into an empty matchit router, +/// the router axum matches with. A path that fails would panic at +/// `Router::route`. Conflicts between paths are a separate check. +pub(crate) fn mountable(axum_path: &str) -> Result<(), String> { + if !axum_path.starts_with('/') { + return Err("a path template must start with '/'".to_string()); + } + if axum_path + .split('/') + .any(|segment| segment.starts_with(':') || segment.starts_with('*')) + { + return Err("a path segment must not start with ':' or '*'".to_string()); + } + // A variable is a whole segment (google/api/http.proto `Segment`), checked + // here rather than left to the router, which may accept text around one. + let mut captures = 0usize; + for segment in axum_path.split('/') { + let Some(at) = segment.find(['{', '}']) else { + continue; + }; + let inner = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')); + if at != 0 || inner.is_none_or(|inner| inner.contains(['{', '}'])) { + return Err(format!("`{segment}` is not a whole-segment variable")); + } + if !segment.starts_with("{*") { + captures += 1; + } + } + // The router renames captures `a` to `z` and panics on a 26th (catch-alls + // are not renamed). + if captures > 25 { + return Err(format!( + "{captures} variables; the router holds at most 25 before the last" + )); + } + matchit::Router::new() + .insert(axum_path, ()) + .map_err(|e| e.to_string()) +} + +/// Split the custom verb off `template` when its last segment is a variable +/// or a wildcard: `/v1/{name}:cancel` is `/v1/{name}` and `:cancel`. An empty +/// verb is not one (`Verb = ":" LITERAL`), and anything else after a variable +/// stays in the template for the router to reject. +fn split_verb(template: &str) -> (&str, Option<&str>) { + let segments = split_top_level(template); + let last = segments[segments.len() - 1]; + let start = template.len() - last.len(); + let colon = if last.starts_with('{') { + closing_brace(last).map(|close| close + 1) + } else if last.starts_with("**") { + Some(2) + } else if last.starts_with('*') { + Some(1) + } else { + None + }; + match colon { + Some(colon) if last[colon..].starts_with(':') && last.len() > colon + 1 => { + let at = start + colon; + (&template[..at], Some(&template[at..])) + } + _ => (template, None), + } +} + +/// `raw` percent-decoded as the value of a variable matching several segments +/// is: every escape but `%2F` and `%2f`, which stay as written +/// (google/api/http.proto). Borrowed when there is nothing to decode. +pub(crate) fn decode_multi_segment(raw: &str) -> Cow<'_, [u8]> { + let is_slash = + |escape: &[u8]| escape[0] == b'%' && escape[1] == b'2' && escape[2] | 0x20 == b'f'; + let bytes = raw.as_bytes(); + if !bytes.windows(3).any(is_slash) { + return percent_encoding::percent_decode(bytes).into(); + } + let mut decoded = Vec::with_capacity(bytes.len()); + let mut rest = bytes; + while let Some(at) = rest.windows(3).position(is_slash) { + decoded.extend(percent_encoding::percent_decode(&rest[..at])); + decoded.extend_from_slice(&rest[at..at + 3]); + rest = &rest[at + 3..]; + } + decoded.extend(percent_encoding::percent_decode(rest)); + Cow::Owned(decoded) +} + +/// Whether `segment` is a variable over several segments: `**`, or a field +/// template with more than one segment or a `**`. +fn spans_segments(segment: &str) -> bool { + segment == "**" + || field_template(segment) + .is_some_and(|template| template.contains('/') || template == "**") +} + +/// Whether `segment` is `**` or a variable bound to it (`{name=**}`). +fn is_double_wildcard(segment: &str) -> bool { + segment == "**" || field_template(segment) == Some("**") +} + +/// Whether `segment` ends in a `**`, alone or as the last component of a +/// field template (`{name=shelves/**}`): it may then match no segment more +/// (google/api/http.proto). +fn ends_in_double_wildcard(segment: &str) -> bool { + is_double_wildcard(segment) + || field_template(segment).is_some_and(|template| template.ends_with("/**")) +} + +/// The template a variable segment binds its field to: `shelves/*` of +/// `{name=shelves/*}`, none for a plain `{name}` or a literal. +fn field_template(segment: &str) -> Option<&str> { + segment + .strip_prefix('{') + .and_then(|s| s.strip_suffix('}')) + .and_then(|inner| inner.split_once('=')) + .map(|(_, template)| template) +} + +/// The index of the `}` closing the `{` that opens `segment`. +fn closing_brace(segment: &str) -> Option { + let mut depth = 0usize; + for (i, ch) in segment.char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + +/// Split a path on `/` boundaries that are NOT inside a `{...}` brace span. +/// +/// google.api.http field templates can embed slashes inside a single capture +/// (e.g. the AIP-127 resource name `{name=shelves/*/books/*}`), so a naive +/// `str::split('/')` would fracture the brace span into invalid fragments. +/// Tracking brace depth keeps each capture intact. Never empty. +fn split_top_level(path: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + + for (i, ch) in path.char_indices() { + match ch { + '{' => depth += 1, + // Decrement only on a matched brace; a stray `}` (malformed input) + // is treated as a literal rather than driving depth negative. + '}' if depth > 0 => depth -= 1, + '/' if depth == 0 => { + segments.push(&path[start..i]); + start = i + 1; + } + _ => {} + } + } + segments.push(&path[start..]); + segments +} + +/// One top-level path segment in axum form. +enum Segment<'a> { + Literal(&'a str), + Capture { name: String, catch_all: bool }, +} + +/// Convert a single top-level path segment from proto template to axum 0.8 form. +/// +/// `is_last` indicates the terminal segment: axum permits a catch-all capture +/// (`{*name}`) only there. Anywhere else one comes only from a template +/// [`MountedPath::new`] already marked unsupported, which is mounted nowhere. +fn convert_segment(segment: &str, idx: usize, is_last: bool) -> Segment<'_> { + if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + // Brace capture, possibly with a `name=template` field path. + if let Some((name, template)) = inner.split_once('=') { + return match template { + // Single-segment field path collapses to a plain capture. + "*" => capture(name, false), + // Multi-segment catch-all maps to axum's `{*name}` (terminal only). + "**" => capture(name, is_last), + // A template of one segment (`{name=foo}`) matches one + // segment: a plain capture, whose value the transcoded routes + // check against the template, so it shares a route with a + // plain variable there rather than a catch-all beside it. + template if !template.contains('/') => capture(name, false), + // Templates with interspersed literals (`{name=shelves/*/books/*}`) + // have no axum form: axum cannot bind literal segments into one + // capture. The last segment is mounted as a catch-all and the + // transcoded routes check the value against the template. + _ => capture(name, is_last), + }; + } + // Plain `{name}` is already valid axum 0.8 syntax. + return capture(inner, false); + } + + // Bare wildcards: named by position, so they never collide with each + // other, and starting with a digit, so never with a field path (whose + // names are identifiers) or a spelled-out template's `{field}.{k}`. + match segment { + "**" => capture(&idx.to_string(), is_last), + "*" => capture(&idx.to_string(), false), + literal => Segment::Literal(literal), + } +} + +fn capture(name: &str, catch_all: bool) -> Segment<'static> { + Segment::Capture { + name: name.to_owned(), + catch_all, + } +} diff --git a/src/transcode/request.rs b/src/transcode/request.rs index 9841988..a66e1ca 100644 --- a/src/transcode/request.rs +++ b/src/transcode/request.rs @@ -20,6 +20,29 @@ use serde_json::Value as JsonValue; use presence::{has_special_json, OneEntry, Presence, Recording}; +/// The path parameters of a request: each dotted field path the path binds, +/// with its value. +pub(crate) trait PathFields { + fn fields(&self) -> impl Iterator; + + fn is_empty(&self) -> bool { + self.fields().next().is_none() + } +} + +impl PathFields for HashMap { + fn fields(&self) -> impl Iterator { + self.iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + } +} + +impl PathFields for [(&str, Cow<'_, str>)] { + fn fields(&self) -> impl Iterator { + self.iter().map(|(key, value)| (*key, value.as_ref())) + } +} + /// How the HTTP request body maps onto the gRPC request message. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -156,6 +179,17 @@ pub fn build_request_message( body: Body<'_>, path_params: &HashMap, raw_query: Option<&str>, +) -> Result { + build_message(input, mapping, body, path_params, raw_query) +} + +/// [`build_request_message`] for any form of the path parameters. +pub(crate) fn build_message( + input: &MessageDescriptor, + mapping: &BodyMapping, + body: Body<'_>, + path_params: &P, + raw_query: Option<&str>, ) -> Result { // Parsed without allocating wherever nothing needs unescaping. let query: Vec<(Cow<'_, str>, Cow<'_, str>)> = match raw_query { @@ -174,11 +208,11 @@ enum Binding { Fill, } -fn build( +fn build( input: &MessageDescriptor, mapping: &BodyMapping, body: Body<'_>, - path_params: &HashMap, + path_params: &P, query: &[(Cow<'_, str>, Cow<'_, str>)], ) -> Result { // What the body and the path set matters only when a query can fill. @@ -223,13 +257,13 @@ fn build( let root_inside = has_special_json(input); // Path params win over everything (the router already matched them). - for (key, raw) in path_params { + for (key, raw) in path_params.fields() { let target = Target { root: input, inside: root_inside, prefix: None, key, - values: &[raw.as_str()], + values: &[raw], }; inside |= bind( &mut message, @@ -252,7 +286,7 @@ fn build( start, Binding::Fill, Some(presence), - None, + None::<&P>, &mut path, )?; } @@ -342,11 +376,11 @@ struct Target<'a> { /// never decides the request, even when it is not a valid value of that field. /// Such a body fails the one pass; it is then read again without those keys. /// Only a failing body pays for the second read. -fn json_body( +fn json_body( input: &MessageDescriptor, mapping: &BodyMapping, bytes: &[u8], - path_params: &HashMap, + path_params: &P, mut presence: Option<&mut Presence>, ) -> Result { // A `null` body sets nothing, like `{}`. @@ -377,13 +411,13 @@ fn json_body( /// Remove from `body` every key the path binds, under its JSON or proto name: /// the path sets those fields whatever the body holds. -fn strip_path_fields( +fn strip_path_fields( body: &mut JsonValue, input: &MessageDescriptor, mapping: &BodyMapping, - path_params: &HashMap, + path_params: &P, ) { - for key in path_params.keys() { + for (key, _) in path_params.fields() { let mut segments = key.split('.'); let desc = match mapping { BodyMapping::Root => input.clone(), @@ -483,10 +517,10 @@ fn deserialize_input<'de, D: Deserializer<'de>>( /// Bind a form body field by field, into the input message or the message /// field the mapping names. -fn form_body( +fn form_body( input: &MessageDescriptor, mapping: &BodyMapping, - form: Form<'_, '_>, + form: Form<'_, '_, P>, mut presence: Option<&mut Presence>, path: &mut Vec, ) -> Result<(DynamicMessage, bool), String> { @@ -529,10 +563,9 @@ fn form_body( } /// A form body and the path parameters that override it. -#[derive(Clone, Copy)] -struct Form<'p, 'q> { +struct Form<'p, 'q, P: ?Sized> { pairs: &'p [(Cow<'q, str>, Cow<'q, str>)], - path_params: &'p HashMap, + path_params: &'p P, } /// Whether the dotted path key `bound` names the field at the end of `path` @@ -562,13 +595,13 @@ struct Start<'a> { /// Keys are grouped by the field they name, not by spelling: `tag_ids` and /// `tagIds` are one field, and binding them apart would let one replace the /// other's values. -fn bind_pairs( +fn bind_pairs( message: &mut DynamicMessage, pairs: &[(Cow<'_, str>, Cow<'_, str>)], start: Start<'_>, binding: Binding, mut presence: Option<&mut Presence>, - path_params: Option<&HashMap>, + path_params: Option<&P>, path: &mut Vec, ) -> Result { // A stable sort keeps the values of each spelling in request order. @@ -668,17 +701,17 @@ fn bind_pairs( /// Assign `values` to the resolved `path` unless a path parameter binds it. /// True when it was assigned. -fn bind_resolved( +fn bind_resolved( message: &mut DynamicMessage, path: &[FieldDescriptor], values: &[&str], binding: Binding, presence: Option<&mut Presence>, - path_params: Option<&HashMap>, + path_params: Option<&P>, ) -> Result { // The path sets these fields whatever the form holds, so their form // values are never decoded, as a JSON body's are not. - if path_params.is_some_and(|bound| bound.keys().any(|key| covers(key, path))) { + if path_params.is_some_and(|bound| bound.fields().any(|(key, _)| covers(key, path))) { return Ok(false); } assign(message, path, values, binding, presence)?; diff --git a/src/transcode/table.rs b/src/transcode/table.rs new file mode 100644 index 0000000..2bca486 --- /dev/null +++ b/src/transcode/table.rs @@ -0,0 +1,1044 @@ +//! The transcoded routes: the bindings mounted at each router path, and the +//! choice among them for a request, by the custom verb its path ends in and +//! by its method. +//! +//! A request's verb is its last segment from the first unencoded `:`. Neither +//! side of the verb holds one: a client percent-encodes every reserved +//! character of a variable's value, and a `LITERAL` writes its reserved +//! characters percent-encoded too (google/api/http.proto). A verb some binding +//! of the request's path binds owns that URL; a verb none binds stays part of +//! the last variable, as Envoy's transcoder and grpc-gateway leave an unbound +//! verb. + +use std::borrow::Cow; +use std::ops::ControlFlow; + +use axum::http::{HeaderValue, Method}; +use rustc_hash::FxHashMap; + +use super::path::{decode_multi_segment, Composite, MountedPath, Part}; +use super::rule::RouteMethod; +use super::{PathParams, RouteEntry}; + +/// Every transcoded route, and where each custom verb is bound. +pub(super) struct Routes { + pub(super) tables: Vec, + /// For each verb, the tables binding it. The router matches a request to + /// one path whatever its verb, while the verb may be bound on a path of + /// another shape the request also matches. + verbs: FxHashMap, + /// The tables with a binding without a verb: a path whose bindings all + /// carry verbs must not hide one of these from a request that names no + /// bound verb. + plain: Index, + /// Each table by its router path, as the router reports the route it + /// matched. + by_path: FxHashMap, + /// Each table's path alone: with which captures a request matches it. + single: Vec>, +} + +/// Some of the tables, by the segments of their router paths: whole-segment +/// literals, variables and a last catch-all, nothing else (`mountable`). +/// +/// It lists the tables a path matches in the order the router ranks them, a +/// literal before a variable before a catch-all at the first segment where +/// two differ, and backtracking past a branch that does not match, as matchit +/// does. Each segment of a request follows at most three branches, so the +/// work is bounded by the request's path, not by the number of tables. +struct Index { + /// `nodes[0]` is the root, before the first segment. + nodes: Vec, + /// [`empty_last_matches`], read once. + empty_last: bool, +} + +/// Whether the router lets a variable take an empty last segment (`/m/` for +/// `/m/{key}`): matchit 0.8.4 does not, later 0.8 releases do. The router is +/// asked, as the proxy links the one version axum routes with. +fn empty_last_matches() -> bool { + static MATCHES: std::sync::OnceLock = std::sync::OnceLock::new(); + *MATCHES.get_or_init(|| { + let mut router = matchit::Router::new(); + router.insert("/m/{key}", ()).is_ok() && router.at("/m/").is_ok() + }) +} + +#[derive(Default)] +struct Node { + literals: FxHashMap, + variable: Option, + /// The table whose path ends in a catch-all here. + rest: Option, + /// The table whose path ends here. + table: Option, +} + +impl Default for Index { + fn default() -> Self { + Self { + nodes: vec![Node::default()], + empty_last: empty_last_matches(), + } + } +} + +impl Index { + fn insert(&mut self, path: &str, table: usize) { + let slot = self.slot(path); + debug_assert!(slot.is_none(), "{path}"); + *slot = Some(table); + } + + /// Whether `path`, a router path, is one this index holds: whole-segment + /// literals and variables, and a catch-all only last. + fn holds(path: &str) -> bool { + let Some(segments) = path.strip_prefix('/') else { + return false; + }; + let mut segments = segments.split('/').peekable(); + while let Some(segment) = segments.next() { + let last = segments.peek().is_none(); + let variable = segment + .strip_prefix('{') + .and_then(|inner| inner.strip_suffix('}')); + match variable { + Some(name) if name.starts_with('*') && !last => return false, + Some(name) if name.contains(['{', '}']) => return false, + Some(_) => {} + None if segment.contains(['{', '}']) => return false, + None => {} + } + } + true + } + + /// The entry of `path`, made on the way: where a table ending there, or + /// in a catch-all there, is held. + fn slot(&mut self, path: &str) -> &mut Option { + let mut node = 0; + let segments = path.strip_prefix('/').unwrap_or(path).split('/'); + for segment in segments { + if segment.starts_with("{*") { + return &mut self.nodes[node].rest; + } + let next = self.nodes.len(); + let child = if segment.starts_with('{') { + *self.nodes[node].variable.get_or_insert(next) + } else { + *self.nodes[node] + .literals + .entry(segment.to_owned()) + .or_insert(next) + }; + if child == next { + self.nodes.push(Node::default()); + } + node = child; + } + &mut self.nodes[node].table + } + + /// The best-ranked table `path` matches. + fn first(&self, path: &str) -> Option { + match self.each(path, ControlFlow::Break) { + ControlFlow::Break(table) => Some(table), + ControlFlow::Continue(()) => None, + } + } + + /// Hand `found` the tables `path` matches, best-ranked first, until it + /// breaks with a value. + fn each( + &self, + path: &str, + mut found: impl FnMut(usize) -> ControlFlow, + ) -> ControlFlow { + match path.strip_prefix('/') { + Some(rest) => self.visit(0, Some(rest), &mut found), + None => ControlFlow::Continue(()), + } + } + + /// `rest` is what follows the segments `node` stands for, `None` past the + /// last one. As in the router, a variable takes an empty segment before + /// another one, and the last one when the router's version does (see + /// [`empty_last_matches`]); a catch-all takes a non-empty rest. + fn visit( + &self, + node: usize, + rest: Option<&str>, + found: &mut impl FnMut(usize) -> ControlFlow, + ) -> ControlFlow { + let empty_last = self.empty_last; + let node = &self.nodes[node]; + let Some(rest) = rest else { + return node.table.map_or(ControlFlow::Continue(()), found); + }; + let (segment, tail) = match rest.split_once('/') { + Some((segment, tail)) => (segment, Some(tail)), + None => (rest, None), + }; + if let Some(&next) = node.literals.get(segment) { + self.visit(next, tail, found)?; + } + if let Some(next) = node + .variable + .filter(|_| !segment.is_empty() || tail.is_some() || empty_last) + { + self.visit(next, tail, found)?; + } + match node.rest.filter(|_| !rest.is_empty()) { + Some(table) => found(table), + None => ControlFlow::Continue(()), + } + } +} + +/// Every binding the router serves at one path. +pub(super) struct PathTable { + /// The router path, written with the first binding's capture names. + pub(super) path: String, + /// The capture names of `path`, in path order. + pub(super) captures: Vec, + bindings: Vec, + /// Whether a binding has no verb. + plain: bool, +} + +/// One binding of a [`PathTable`]. +struct Binding { + entry: RouteEntry, + /// This binding's names for the captures of the table's path, when they + /// differ from the path's own. + names: Option>, + verb: Option, + template: Option