Skip to main content

structured_proxy/
lib.rs

1//! Universal gRPC→REST transcoding proxy.
2//!
3//! Config-driven: same binary, different YAML = different product proxy.
4//! Works with ANY gRPC service via proto descriptors as config.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! structured-proxy --config sid-proxy.yaml
10//! structured-proxy --config sflow-proxy.yaml
11//! ```
12//!
13//! ## JWT verification
14//!
15//! The bearer-token check sits behind [`hooks::TokenVerifier`]. A build gets one
16//! of two:
17//!
18//! - the **built-in** verifier (keys from `auth.jwt`), whose crypto backend is
19//!   picked by a feature: `rust_crypto` (default, pure Rust) or `aws_lc_rs`
20//!   (opt-in, constant-time / FIPS-capable, links aws-lc via C FFI). Both may be
21//!   compiled in at once — Cargo features are additive, so a dependency graph
22//!   with two dependents asking for different backends unifies into exactly that
23//!   build. `aws_lc_rs` then wins: it is constant-time and free of the `rsa`
24//!   advisory `rust_crypto` carries. [`ProxyServer::from_config`] settles that
25//!   choice for the process; a process where another crate may reach
26//!   `jsonwebtoken` before any server exists calls
27//!   [`install_default_crypto_provider`] from `main` instead.
28//! - an **injected** one, supplied by the embedder through
29//!   [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a
30//!   whole dependency graph, a backend feature cannot be chosen per binary —
31//!   injection is how a consumer that needs a different one gets it without
32//!   deciding for everyone else who links this crate. Such a build takes
33//!   `default-features = false` and links no JWT crypto at all.
34
35// `builtin_jwt` is implied by each backend and never meant to stand alone: on
36// its own it would link jsonwebtoken with no provider, which panics at runtime.
37#[cfg(all(
38    feature = "builtin_jwt",
39    not(any(feature = "rust_crypto", feature = "aws_lc_rs"))
40))]
41compile_error!(
42    "feature `builtin_jwt` needs a crypto backend: enable `rust_crypto` or `aws_lc_rs` \
43     (or neither, and inject a verifier with `ProxyServer::with_token_verifier`)"
44);
45
46pub mod auth;
47pub mod config;
48mod embed;
49pub mod hooks;
50pub mod oidc;
51pub mod openapi;
52pub mod shield;
53mod tls;
54pub mod transcode;
55
56/// Settle the process-wide JWT crypto provider. See
57/// [`install_default_crypto_provider`] for when a call is needed.
58#[cfg(feature = "builtin_jwt")]
59pub use auth::crypto::install_default_crypto_provider;
60
61use axum::extract::State;
62use axum::http::{Request, StatusCode};
63use axum::middleware::Next;
64use axum::response::{IntoResponse, Response};
65use axum::routing::get;
66use axum::{Json, Router};
67use prost_reflect::DescriptorPool;
68use std::net::SocketAddr;
69use tower_http::cors::{AllowOrigin, CorsLayer};
70use tower_http::trace::TraceLayer;
71
72use std::sync::Arc;
73
74use config::{DescriptorSource, ProxyConfig};
75use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier};
76
77/// Shared state for all proxy handlers.
78#[derive(Clone, Debug)]
79pub struct ProxyState {
80    /// Service name from config.
81    pub service_name: String,
82    /// gRPC upstream address.
83    pub grpc_upstream: String,
84    /// Lazy gRPC channel to upstream service.
85    pub grpc_channel: tonic::transport::Channel,
86    /// Maintenance mode active.
87    pub maintenance_mode: bool,
88    /// Maintenance exempt path patterns.
89    pub maintenance_exempt: Vec<String>,
90    /// Maintenance message.
91    pub maintenance_message: String,
92    /// Headers to forward from HTTP to gRPC.
93    pub forwarded_headers: Vec<String>,
94    /// Metrics namespace (derived from service name).
95    pub metrics_namespace: String,
96    /// Path class patterns for metrics.
97    pub metrics_classes: Vec<config::MetricsClassConfig>,
98    /// SSE keep-alive interval (seconds) for server-streaming responses.
99    pub sse_keep_alive_secs: u64,
100}
101
102/// Universal proxy server.
103pub struct ProxyServer {
104    config: ProxyConfig,
105    /// Optional pre-loaded descriptor pool (for embedded mode).
106    descriptor_pool: Option<DescriptorPool>,
107    /// Optional in-process forward-auth/PDP gate (embedded Tier-2 hook).
108    auth_decider: Option<Arc<dyn AuthDecider>>,
109    /// Optional stateless OIDC surface backing (embedded Tier-2 hook).
110    oidc_backend: Option<Arc<dyn OidcBackend>>,
111    /// Embedder-supplied extra stateless routes (embedded Tier-2 hook).
112    extra_routes: Vec<ExtraRoute>,
113    /// Override for the `/verify` forward-auth path of an injected AuthDecider.
114    verify_path: Option<String>,
115    /// Embedder-supplied JWT verifier, replacing the built-in one.
116    token_verifier: Option<Arc<dyn TokenVerifier>>,
117    /// How the transcoded routes render errors and frame NDJSON streams.
118    transcode: transcode::TranscodeOptions,
119}
120
121impl ProxyServer {
122    /// Create from YAML config file.
123    pub fn from_config(config: ProxyConfig) -> Self {
124        // Earliest point this crate owns: settle the JWT crypto provider here,
125        // long before the first token arrives. A process whose other crates
126        // reach jsonwebtoken before any server exists calls
127        // `install_default_crypto_provider` from `main` instead.
128        #[cfg(feature = "builtin_jwt")]
129        auth::crypto::install_default_crypto_provider();
130
131        Self {
132            config,
133            descriptor_pool: None,
134            auth_decider: None,
135            oidc_backend: None,
136            extra_routes: Vec::new(),
137            verify_path: None,
138            token_verifier: None,
139            transcode: transcode::TranscodeOptions::default(),
140        }
141    }
142
143    /// Create from a YAML document: the [`ProxyConfig`] plus the transcoding
144    /// settings it does not hold (`error_details:` and
145    /// `streaming.ndjson_envelope`), applied as [`with_error_details`] and
146    /// [`with_ndjson_envelope`] would. A top-level or `streaming:` key no
147    /// setting reads is logged as a warning.
148    ///
149    /// # Errors
150    ///
151    /// Invalid YAML, a [`ProxyConfig`] that fails
152    /// [`validate`](ProxyConfig::validate), or an `error_details` route pattern
153    /// that is relative or not a valid glob.
154    ///
155    /// [`with_error_details`]: Self::with_error_details
156    /// [`with_ndjson_envelope`]: Self::with_ndjson_envelope
157    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
158        let config = ProxyConfig::from_yaml_str(yaml)?;
159        for key in config::unknown_config_keys(yaml) {
160            tracing::warn!(%key, "unknown config key is ignored");
161        }
162        let settings: config::TranscodeFileConfig = serde_yaml::from_str(yaml)?;
163        let options = settings
164            .options()
165            .map_err(|e| anyhow::anyhow!("invalid error_details config: {e}"))?;
166        let mut server = Self::from_config(config);
167        server.transcode = options;
168        Ok(server)
169    }
170
171    /// [`from_yaml_str`](Self::from_yaml_str) on the contents of a file.
172    ///
173    /// # Errors
174    ///
175    /// The file cannot be read, or its contents are rejected by
176    /// [`from_yaml_str`](Self::from_yaml_str).
177    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
178        Self::from_yaml_str(&std::fs::read_to_string(path)?)
179    }
180
181    /// The configuration this server was created with.
182    pub fn config(&self) -> &ProxyConfig {
183        &self.config
184    }
185
186    /// Create with an embedded descriptor pool (for sid-proxy backward compat).
187    pub fn with_descriptors(mut self, pool: DescriptorPool) -> Self {
188        self.descriptor_pool = Some(pool);
189        self
190    }
191
192    /// Inject an in-process forward-auth / PDP decision (embedded Tier-2 hook).
193    ///
194    /// The decider gates every proxied request inline and also backs the
195    /// `/verify` forward-auth endpoint. Its signature is `axum`-free (see
196    /// [`hooks::AuthDecider`]), so the embedder never names an HTTP framework.
197    pub fn with_auth_decider(mut self, decider: Arc<dyn AuthDecider>) -> Self {
198        self.auth_decider = Some(decider);
199        self
200    }
201
202    /// Back the stateless OIDC surface (discovery, JWKS, userinfo) with the
203    /// embedder's key/client metadata (embedded Tier-2 hook).
204    ///
205    /// When set, this supersedes the config-driven static `oidc_discovery`
206    /// routes. See [`hooks::OidcBackend`].
207    pub fn with_oidc_backend(mut self, backend: Arc<dyn OidcBackend>) -> Self {
208        self.oidc_backend = Some(backend);
209        self
210    }
211
212    /// Register extra stateless routes through an `axum`-free adapter (embedded
213    /// Tier-2 hook). See [`hooks::ExtraRoute`] / [`hooks::ExtraRouteHandler`].
214    pub fn with_extra_routes(mut self, routes: impl IntoIterator<Item = ExtraRoute>) -> Self {
215        self.extra_routes.extend(routes);
216        self
217    }
218
219    /// Set the path at which the injected [`AuthDecider`] answers forward-auth
220    /// sub-requests (`/verify`). Independent of any JWT `forward_auth` config, so
221    /// a decider-only embedder can place it without a JWT block.
222    ///
223    /// Resolution order for the path: this override, then
224    /// `auth.forward_auth.path` from config, then the default `/auth/verify`.
225    pub fn with_verify_path(mut self, path: impl Into<String>) -> Self {
226        self.verify_path = Some(path.into());
227        self
228    }
229
230    /// Verify bearer tokens with the embedder's own verifier instead of the
231    /// built-in one (embedded Tier-2 hook).
232    ///
233    /// The JWT middleware keeps everything around the signature check — route
234    /// policies, the roles claim, claim→header forwarding — and takes the
235    /// verdict from [`hooks::TokenVerifier`]. Use this when the built-in crypto
236    /// backend is not the one this binary needs: a validated / FIPS module, an
237    /// HSM, or a verifier the embedder already owns. It is also the way out of
238    /// Cargo's feature unification, which makes `rust_crypto` / `aws_lc_rs` a
239    /// property of the whole dependency graph rather than of one binary.
240    ///
241    /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt`
242    /// then only configures claim forwarding, and any key source in it is
243    /// ignored (with a warning), since the verifier owns its own keys.
244    pub fn with_token_verifier(mut self, verifier: Arc<dyn TokenVerifier>) -> Self {
245        self.token_verifier = Some(verifier);
246        self
247    }
248
249    /// Choose which transcoded routes return the upstream's
250    /// `google.rpc.Status` details in their error bodies. Every route does by
251    /// default; see [`transcode::error::ErrorDetailsPolicy`] to switch them
252    /// off globally or per route.
253    pub fn with_error_details(mut self, policy: transcode::error::ErrorDetailsPolicy) -> Self {
254        self.transcode = self.transcode.with_error_details(policy);
255        self
256    }
257
258    /// Wrap NDJSON stream lines in `{"result"}` / `{"error"}` envelopes; see
259    /// [`transcode::TranscodeOptions::with_ndjson_envelope`].
260    pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
261        self.transcode = self.transcode.with_ndjson_envelope(enabled);
262        self
263    }
264
265    /// Load descriptor pool from configured sources.
266    ///
267    /// Multiple descriptor files are merged into a single pool,
268    /// enabling multi-service proxying from one binary.
269    fn load_descriptors(&self) -> anyhow::Result<DescriptorPool> {
270        if let Some(pool) = &self.descriptor_pool {
271            return Ok(pool.clone());
272        }
273
274        let mut pool = DescriptorPool::new();
275
276        for source in &self.config.descriptors {
277            match source {
278                DescriptorSource::File { file } => {
279                    let bytes = std::fs::read(file).map_err(|e| {
280                        anyhow::anyhow!("Failed to read descriptor file {:?}: {}", file, e)
281                    })?;
282                    pool.decode_file_descriptor_set(bytes.as_slice())
283                        .map_err(|e| {
284                            anyhow::anyhow!("Failed to decode descriptor file {:?}: {}", file, e)
285                        })?;
286                    tracing::info!("Loaded descriptor from {:?}", file);
287                }
288                DescriptorSource::Reflection { reflection } => {
289                    tracing::warn!(
290                        "gRPC reflection client not supported — use descriptor files instead (reflection endpoint: {})",
291                        reflection
292                    );
293                }
294                DescriptorSource::Embedded { bytes } => {
295                    pool.decode_file_descriptor_set(*bytes).map_err(|e| {
296                        anyhow::anyhow!("Failed to decode embedded descriptors: {}", e)
297                    })?;
298                }
299            }
300        }
301
302        Ok(pool)
303    }
304
305    /// The path an injected [`AuthDecider`] answers `/verify` at: the
306    /// `with_verify_path` override, then `auth.forward_auth.path`, then the
307    /// default `/auth/verify`. Only meaningful when a decider is set (the
308    /// override does not apply to config-driven JWT forward-auth).
309    fn decider_verify_path(&self) -> String {
310        self.verify_path.clone().unwrap_or_else(|| {
311            self.config
312                .auth
313                .as_ref()
314                .and_then(|a| a.forward_auth.as_ref())
315                .map(|fa| fa.path.clone())
316                .unwrap_or_else(|| "/auth/verify".to_string())
317        })
318    }
319
320    /// The verify path that is ACTUALLY mounted, or `None` when no verify route
321    /// is mounted. This is what the collision guard and maintenance-exempt list
322    /// must use, since the two mount sites use different paths:
323    /// - an injected decider mounts at [`decider_verify_path`](Self::decider_verify_path)
324    ///   (the `with_verify_path` override applies), whereas
325    /// - config-driven JWT forward-auth mounts `forward_auth.routes()` at
326    ///   `auth.forward_auth.path` (the override does NOT apply, and it mounts
327    ///   only when `auth.mode == "jwt"`, since the endpoint shares the built JWT
328    ///   `Auth`).
329    fn mounted_verify_path(&self) -> Option<String> {
330        if self.auth_decider.is_some() {
331            return Some(self.decider_verify_path());
332        }
333        self.config.auth.as_ref().and_then(|a| {
334            if a.mode != "jwt" {
335                return None;
336            }
337            a.forward_auth
338                .as_ref()
339                .filter(|fa| fa.enabled)
340                .map(|fa| fa.path.clone())
341        })
342    }
343
344    /// Every `(method, path)` route mounted before the verify endpoint, used to
345    /// reject a real collision with a clear error instead of an axum
346    /// duplicate-route panic. `method` is the uppercase HTTP token; same-path
347    /// routes with different methods do NOT collide (the extra-route adapter and
348    /// axum merge them), so the key is the pair, not the path alone.
349    ///
350    /// Must stay exhaustive: health probes, metrics, OpenAPI spec/docs, the OIDC
351    /// surface (injected backend or config-driven static discovery), embedder
352    /// extra routes, and the transcoded REST routes. All built-in surfaces here
353    /// are `GET`.
354    fn reserved_routes(&self, pool: &DescriptorPool) -> anyhow::Result<Vec<(String, String)>> {
355        let mut routes = Vec::new();
356        let mut get = |path: String| routes.push(("GET".to_string(), path));
357        if self.config.health.enabled {
358            get(self.config.health.path.clone());
359            get(self.config.health.live_path.clone());
360            get(self.config.health.ready_path.clone());
361            get(self.config.health.startup_path.clone());
362        }
363        if self.config.metrics.enabled {
364            get(self.config.metrics.path.clone());
365        }
366        if let Some(openapi) = self.config.openapi.as_ref().filter(|o| o.enabled) {
367            get(openapi.path.clone());
368            get(openapi.docs_path.clone());
369        }
370        // OIDC: an injected backend supersedes config-driven static discovery.
371        if let Some(backend) = &self.oidc_backend {
372            for doc in backend.metadata_documents() {
373                get(doc.path);
374            }
375            get(backend.jwks().path);
376            get(backend.userinfo_path());
377        } else if let Some(cfg) = &self.config.oidc_discovery {
378            if let Some(oidc) = oidc::Oidc::build(cfg)
379                .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
380            {
381                for path in oidc.paths() {
382                    get(path);
383                }
384            }
385        }
386        for route in &self.extra_routes {
387            routes.push((route.method.as_str().to_string(), route.path.clone()));
388        }
389        routes.extend(transcode::route_paths(pool, &self.config.aliases));
390        Ok(routes)
391    }
392
393    /// Build the axum router with all endpoints.
394    pub fn router(&self) -> anyhow::Result<Router> {
395        // Enforce cross-field invariants on the embedded path too, where the
396        // config is built directly instead of through `from_yaml_str`.
397        self.config.validate()?;
398        let pool = self.load_descriptors()?;
399
400        let grpc_upstream = self.config.upstream.default.clone();
401        let grpc_channel = tonic::transport::Channel::from_shared(grpc_upstream.clone())
402            .map_err(|e| anyhow::anyhow!("invalid gRPC upstream URL: {}", e))?
403            .connect_timeout(std::time::Duration::from_secs(5))
404            .timeout(std::time::Duration::from_secs(5))
405            .connect_lazy();
406
407        let service_name = self.config.service.name.clone();
408        let metrics_namespace = service_name.replace('-', "_");
409
410        // The verify path that is actually mounted (branch-correct), if any.
411        let verify_path = self.mounted_verify_path();
412
413        // Validate the WHOLE mounted edge BEFORE any router is built, so a
414        // malformed path (missing leading '/') or a collision (between built-in
415        // routes, the OIDC surface, embedder extra routes, transcoded paths, or
416        // the verify endpoint) is a clear error instead of an axum panic at
417        // `.route`/`.merge`. Collisions are keyed by (method, path): same-path
418        // routes with different methods are legal (they merge), so only a
419        // repeated (method, path) — or any overlap with the verify endpoint,
420        // which answers ALL methods (`*`) — is a real conflict.
421        let mut mounted = self.reserved_routes(&pool)?;
422        if let Some(vp) = &verify_path {
423            mounted.push(("*".to_string(), vp.clone()));
424        }
425        // Key by NORMALIZED shape, not raw text: axum/matchit treats two dynamic
426        // routes with the same structure but different param names (e.g.
427        // `/v1/x/{a}` and `/v1/x/{b}`) as a conflict, so they must collide here.
428        let mut methods_by_shape: std::collections::HashMap<
429            String,
430            std::collections::HashSet<&str>,
431        > = std::collections::HashMap::new();
432        for (method, path) in &mounted {
433            if !path.starts_with('/') {
434                anyhow::bail!("route path {path:?} must start with '/'");
435            }
436            let methods = methods_by_shape
437                .entry(normalize_route_shape(path))
438                .or_default();
439            // `*` (the verify endpoint) claims every method, so it conflicts with
440            // any other route on the same shape, and vice versa.
441            let conflict = if method == "*" {
442                !methods.is_empty()
443            } else {
444                methods.contains("*") || methods.contains(method.as_str())
445            };
446            if conflict {
447                anyhow::bail!("route path {path:?} is registered by more than one endpoint");
448            }
449            methods.insert(method.as_str());
450        }
451
452        // Keep the actually-configured probe / metrics / verify paths reachable
453        // under maintenance mode. The default exempt list names the default
454        // paths; once those are relocated via config, the relocated paths must
455        // be exempted too, or maintenance would 503 probe and forward-auth
456        // traffic that was intentionally exempt before.
457        let mut maintenance_exempt = self.config.maintenance.exempt_paths.clone();
458        if self.config.health.enabled {
459            maintenance_exempt.push(self.config.health.path.clone());
460            maintenance_exempt.push(self.config.health.live_path.clone());
461            maintenance_exempt.push(self.config.health.ready_path.clone());
462            maintenance_exempt.push(self.config.health.startup_path.clone());
463        }
464        if self.config.metrics.enabled {
465            maintenance_exempt.push(self.config.metrics.path.clone());
466        }
467        if let Some(vp) = &verify_path {
468            maintenance_exempt.push(vp.clone());
469        }
470
471        let state = ProxyState {
472            service_name: service_name.clone(),
473            grpc_upstream,
474            grpc_channel,
475            maintenance_mode: self.config.maintenance.enabled,
476            maintenance_exempt,
477            maintenance_message: self.config.maintenance.message.clone(),
478            forwarded_headers: self.config.forwarded_headers.clone(),
479            metrics_namespace,
480            metrics_classes: self.config.metrics_classes.clone(),
481            sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs,
482        };
483
484        let cors = self.build_cors();
485
486        // Build transcoding routes from descriptor pool.
487        let mut transcode_routes =
488            transcode::routes_with_options(&pool, &self.config.aliases, &self.transcode);
489
490        // External authorization (Envoy ext_authz) gates only the proxied API
491        // routes, never health / metrics / discovery. It runs inside the auth
492        // layer below, so the Check call sees the identity headers the JWT
493        // middleware injected.
494        let authz = match self.config.auth.as_ref().and_then(|a| a.authz.as_ref()) {
495            Some(cfg) => auth::authz::Authz::build(cfg)
496                .map_err(|e| anyhow::anyhow!("invalid authz config: {e}"))?,
497            None => None,
498        };
499
500        // Order matters: in axum the LAST-added layer is outermost and runs
501        // FIRST. We want `authz -> AuthDecider -> handler`, so add the decider
502        // layer first (inner) and the authz layer second (outer). That way, when
503        // both are configured, ext_authz runs first and the in-process decider
504        // sees any headers the authz Check injected.
505        if let Some(decider) = &self.auth_decider {
506            transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
507                decider.clone(),
508                embed::auth_decider_gate,
509            ));
510        }
511        if let Some(authz) = authz {
512            transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
513                authz,
514                auth::authz::middleware,
515            ));
516        }
517
518        // Health routes. Paths are configurable; the whole group is skippable.
519        let health_routes = if self.config.health.enabled {
520            let health = &self.config.health;
521            let health_service_name = service_name.clone();
522            Router::new()
523                .route(
524                    &health.path,
525                    get({
526                        let name = health_service_name.clone();
527                        move || async move {
528                            Json(serde_json::json!({
529                                "status": "ok",
530                                "service": name,
531                            }))
532                        }
533                    }),
534                )
535                .route(&health.live_path, get(|| async { StatusCode::OK }))
536                .route(
537                    &health.ready_path,
538                    get(|State(state): State<ProxyState>| async move {
539                        let mut client =
540                            tonic_health::pb::health_client::HealthClient::new(state.grpc_channel);
541                        match client
542                            .check(tonic_health::pb::HealthCheckRequest {
543                                service: String::new(),
544                            })
545                            .await
546                        {
547                            Ok(resp) => {
548                                let status = resp.into_inner().status;
549                                if status
550                                    == tonic_health::pb::health_check_response::ServingStatus::Serving
551                                        as i32
552                                {
553                                    StatusCode::OK
554                                } else {
555                                    StatusCode::SERVICE_UNAVAILABLE
556                                }
557                            }
558                            Err(_) => StatusCode::SERVICE_UNAVAILABLE,
559                        }
560                    }),
561                )
562                .route(&health.startup_path, get(|| async { StatusCode::OK }))
563        } else {
564            Router::new()
565        };
566
567        // Metrics route. Path is configurable; the endpoint is skippable.
568        let metrics_routes = if self.config.metrics.enabled {
569            Router::new().route(
570                &self.config.metrics.path,
571                get(|| async {
572                    let encoder = prometheus::TextEncoder::new();
573                    let metric_families = prometheus::default_registry().gather();
574                    match encoder.encode_to_string(&metric_families) {
575                        Ok(text) => (
576                            StatusCode::OK,
577                            [(
578                                axum::http::header::CONTENT_TYPE,
579                                "text/plain; version=0.0.4; charset=utf-8",
580                            )],
581                            text,
582                        )
583                            .into_response(),
584                        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
585                    }
586                }),
587            )
588        } else {
589            Router::new()
590        };
591
592        // OpenAPI + docs routes (if enabled).
593        let openapi_routes = self.build_openapi_routes(&pool);
594
595        // OIDC routes (public, like the health endpoints). An injected
596        // OidcBackend supersedes the config-driven static discovery: the proxy
597        // hosts the HTTP surface, the embedder supplies the content.
598        let oidc_routes = match &self.oidc_backend {
599            Some(backend) => embed::oidc_backend_routes(backend.clone()),
600            None => match &self.config.oidc_discovery {
601                Some(cfg) => oidc::Oidc::build(cfg)
602                    .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
603                    .map(|o| o.routes())
604                    .unwrap_or_default(),
605                None => Router::new(),
606            },
607        };
608
609        // Rate limiting (Shield), if configured and enabled.
610        let shield = match &self.config.shield {
611            Some(cfg) => shield::Shield::build(cfg)
612                .map_err(|e| anyhow::anyhow!("invalid shield config: {e}"))?,
613            None => None,
614        };
615
616        // JWT auth, if configured (auth.mode == "jwt").
617        let auth = match &self.config.auth {
618            Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone())
619                .map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?,
620            None => None,
621        };
622
623        let mut router = Router::new()
624            .merge(health_routes)
625            .merge(metrics_routes)
626            .merge(openapi_routes)
627            .merge(oidc_routes)
628            .merge(embed::extra_routes_router(&self.extra_routes))
629            .merge(transcode_routes);
630        // CORS is applied as the outermost layer below, so it wraps the auth and
631        // rate-limit enforcement: a short-circuited 401/429/503 still carries CORS
632        // headers, and preflight OPTIONS is answered before auth can reject it.
633
634        // Forward-auth verification endpoint, sharing the built Auth. Mounted
635        // after the auth layer below so the endpoint itself is not gated by the
636        // JWT middleware (it answers the gate, it isn't behind it).
637        let forward_auth = auth.as_ref().and_then(|built| {
638            auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone())
639        });
640
641        // Duplicate-route collisions (including the verify path) were already
642        // rejected up front, before any router was built.
643
644        // Two-phase rate limiting around auth. The post-auth phase (rules keyed
645        // by a validated JWT claim) is layered first so it sits *inside* auth and
646        // sees the verified claims; the pre-auth phase (IP / header keys) is
647        // layered after auth below so it runs *first* and sheds anonymous floods
648        // before any signature verification.
649        if let Some(shield) = &shield {
650            router = router.layer(axum::middleware::from_fn_with_state(
651                shield.clone(),
652                shield::post_auth_middleware,
653            ));
654        }
655
656        if let Some(auth) = auth {
657            router = router.layer(axum::middleware::from_fn_with_state(auth, auth::middleware));
658        }
659
660        // Forward-auth `/verify` endpoint. An injected AuthDecider owns it when
661        // present (in-process PDP); otherwise the config-driven JWT ForwardAuth
662        // backs it. Mounted after the auth layer so it is not itself JWT-gated.
663        if let Some(decider) = &self.auth_decider {
664            // Collision / shape of this path was already validated above.
665            let decider = decider.clone();
666            let path = self.decider_verify_path();
667            router = router.route(
668                &path,
669                axum::routing::any(move |req: axum::extract::Request| {
670                    let decider = decider.clone();
671                    async move { embed::verify_via_decider(decider, req).await }
672                }),
673            );
674        } else if let Some(forward_auth) = &forward_auth {
675            router = router.merge(forward_auth.routes());
676        }
677
678        // Pre-auth phase, added before maintenance so maintenance wraps it (outer
679        // layers run first): a request rejected by the maintenance gate must not
680        // be charged against its rate-limit budget. Placed after the auth layer
681        // so it runs before auth, and after the verify route so that endpoint is
682        // rate-limited too (but not JWT-gated).
683        if let Some(shield) = &shield {
684            router = router.layer(axum::middleware::from_fn_with_state(
685                shield.clone(),
686                shield::pre_auth_middleware,
687            ));
688        }
689
690        let router = router
691            .layer(axum::middleware::from_fn_with_state(
692                state.clone(),
693                maintenance_middleware,
694            ))
695            .layer(TraceLayer::new_for_http())
696            // Outermost: wraps every enforcement layer so short-circuited
697            // responses keep CORS headers, and answers preflight before auth.
698            .layer(cors)
699            .with_state(state);
700
701        Ok(router)
702    }
703
704    fn build_openapi_routes(&self, pool: &DescriptorPool) -> Router<ProxyState> {
705        let openapi_config = match &self.config.openapi {
706            Some(cfg) if cfg.enabled => cfg,
707            _ => return Router::new(),
708        };
709
710        let spec = openapi::generate(pool, openapi_config, &self.config.aliases);
711        let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
712        let openapi_path = openapi_config.path.clone();
713        let docs_path = openapi_config.docs_path.clone();
714        let title = openapi_config
715            .title
716            .clone()
717            .unwrap_or_else(|| self.config.service.name.clone());
718        let openapi_path_for_docs = openapi_path.clone();
719
720        tracing::info!("OpenAPI spec at {}, docs at {}", openapi_path, docs_path,);
721
722        Router::new()
723            .route(
724                &openapi_path,
725                get(move || async move {
726                    (
727                        StatusCode::OK,
728                        [(
729                            axum::http::header::CONTENT_TYPE,
730                            "application/json; charset=utf-8",
731                        )],
732                        spec_json,
733                    )
734                }),
735            )
736            .route(
737                &docs_path,
738                get(move || async move {
739                    let html = openapi::docs_html(&openapi_path_for_docs, &title);
740                    (
741                        StatusCode::OK,
742                        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
743                        html,
744                    )
745                }),
746            )
747    }
748
749    fn build_cors(&self) -> CorsLayer {
750        if self.config.cors.origins.is_empty() {
751            tracing::warn!("CORS origins not set — using permissive CORS (dev mode)");
752            CorsLayer::permissive()
753        } else {
754            let origins: Vec<_> = self
755                .config
756                .cors
757                .origins
758                .iter()
759                .filter_map(|o| o.parse().ok())
760                .collect();
761            CorsLayer::new()
762                .allow_origin(AllowOrigin::list(origins))
763                .allow_methods(tower_http::cors::Any)
764                .allow_headers(tower_http::cors::Any)
765                .allow_credentials(true)
766                .expose_headers([
767                    "grpc-status".parse().unwrap(),
768                    "grpc-message".parse().unwrap(),
769                    // Let browser clients read the rate-limit budget and back off.
770                    "ratelimit-limit".parse().unwrap(),
771                    "ratelimit-remaining".parse().unwrap(),
772                    "ratelimit-reset".parse().unwrap(),
773                    "retry-after".parse().unwrap(),
774                ])
775        }
776    }
777
778    /// Start serving on configured address.
779    pub async fn serve(&self) -> anyhow::Result<()> {
780        let router = self.router()?;
781        let app = router.into_make_service_with_connect_info::<SocketAddr>();
782        let addr: SocketAddr = self.config.listen.http.parse()?;
783        let listener = tokio::net::TcpListener::bind(addr).await?;
784
785        tracing::info!("{} listening on {}", self.config.service.name, addr);
786        axum::serve(listener, app).await?;
787        Ok(())
788    }
789}
790
791/// Canonical shape of an axum route path for collision detection: every dynamic
792/// segment (`{name}` capture or `{*name}` wildcard) is replaced by a
793/// name-independent placeholder, so structurally identical routes that differ
794/// only in parameter name (which axum/matchit rejects as a conflict) map to the
795/// same key. Literal segments are unchanged.
796fn normalize_route_shape(path: &str) -> String {
797    path.split('/')
798        .map(|seg| {
799            if seg.starts_with("{*") && seg.ends_with('}') {
800                "{*}"
801            } else if seg.starts_with('{') && seg.ends_with('}') {
802                "{}"
803            } else {
804                seg
805            }
806        })
807        .collect::<Vec<_>>()
808        .join("/")
809}
810
811/// Maintenance mode middleware.
812async fn maintenance_middleware(
813    State(state): State<ProxyState>,
814    request: Request<axum::body::Body>,
815    next: Next,
816) -> Response {
817    if state.maintenance_mode {
818        let path = request.uri().path();
819        let exempt = state.maintenance_exempt.iter().any(|pattern| {
820            if pattern.ends_with("/**") {
821                let prefix = &pattern[..pattern.len() - 3];
822                path.starts_with(prefix)
823            } else {
824                path == pattern
825            }
826        });
827        if !exempt {
828            return (
829                StatusCode::SERVICE_UNAVAILABLE,
830                [("retry-after", "300")],
831                state.maintenance_message.clone(),
832            )
833                .into_response();
834        }
835    }
836    next.run(request).await
837}
838
839/// Create a lazy gRPC channel for testing (connects to nowhere).
840#[cfg(test)]
841pub(crate) fn test_channel() -> tonic::transport::Channel {
842    tonic::transport::Channel::from_static("http://127.0.0.1:1")
843        .connect_timeout(std::time::Duration::from_millis(100))
844        .connect_lazy()
845}
846
847/// A minimal [`ProxyState`] for tests that only need a state to satisfy a
848/// `Router<ProxyState>` (the hook routers do not read it).
849#[cfg(test)]
850pub(crate) fn test_state() -> ProxyState {
851    ProxyState {
852        service_name: "test".into(),
853        grpc_upstream: "http://127.0.0.1:1".into(),
854        grpc_channel: test_channel(),
855        maintenance_mode: false,
856        maintenance_exempt: vec![],
857        maintenance_message: String::new(),
858        forwarded_headers: vec![],
859        metrics_namespace: "test".into(),
860        metrics_classes: vec![],
861        sse_keep_alive_secs: 15,
862    }
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn normalize_route_shape_collapses_param_names() {
871        // Same shape, different param names → same key.
872        assert_eq!(
873            normalize_route_shape("/v1/x/{profile_id}"),
874            normalize_route_shape("/v1/x/{id}")
875        );
876        // Wildcard vs named capture stay distinct; literals are untouched.
877        assert_eq!(normalize_route_shape("/a/{p}/b"), "/a/{}/b");
878        assert_eq!(normalize_route_shape("/a/{*rest}"), "/a/{*}");
879        assert_ne!(
880            normalize_route_shape("/a/{p}"),
881            normalize_route_shape("/a/b")
882        );
883    }
884
885    #[test]
886    fn test_minimal_config_server() {
887        let yaml = r#"
888upstream:
889  default: "http://127.0.0.1:50051"
890"#;
891        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
892        let server = ProxyServer::from_config(config);
893        assert!(server.descriptor_pool.is_none());
894    }
895
896    #[tokio::test]
897    async fn test_maintenance_exempt_matching() {
898        let state = ProxyState {
899            service_name: "test".into(),
900            grpc_upstream: "http://localhost:50051".into(),
901            grpc_channel: test_channel(),
902            maintenance_mode: true,
903            maintenance_exempt: vec![
904                "/health/**".into(),
905                "/.well-known/**".into(),
906                "/metrics".into(),
907            ],
908            maintenance_message: "Down".into(),
909            forwarded_headers: vec![],
910            metrics_namespace: "test".into(),
911            metrics_classes: vec![],
912            sse_keep_alive_secs: 15,
913        };
914
915        let check = |path: &str| -> bool {
916            state.maintenance_exempt.iter().any(|pattern| {
917                if pattern.ends_with("/**") {
918                    let prefix = &pattern[..pattern.len() - 3];
919                    path.starts_with(prefix)
920                } else {
921                    path == pattern
922                }
923            })
924        };
925
926        assert!(check("/health"));
927        assert!(check("/health/ready"));
928        assert!(check("/.well-known/openid-configuration"));
929        assert!(check("/metrics"));
930        assert!(!check("/v1/auth/login"));
931        assert!(!check("/oauth2/token"));
932    }
933}