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