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