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
56/// The README's Rust examples, compiled as doc tests.
57#[cfg(doctest)]
58#[doc = include_str!("../README.md")]
59struct ReadmeDoctests;
60
61pub mod auth;
62pub mod config;
63mod cors;
64mod embed;
65mod guard;
66mod held;
67pub mod hooks;
68pub mod oidc;
69pub mod openapi;
70mod serve;
71pub mod service;
72pub mod shield;
73mod tls;
74pub mod transcode;
75pub mod upstream;
76
77/// Settle the process-wide JWT crypto provider. See
78/// [`install_default_crypto_provider`] for when a call is needed.
79#[cfg(feature = "builtin_jwt")]
80pub use auth::crypto::install_default_crypto_provider;
81pub use serve::{serve, serve_with, serve_with_shutdown, ServeOptions};
82pub use service::{ConnectionInfo, ProxyService};
83
84use axum::extract::State;
85use axum::http::StatusCode;
86use axum::response::IntoResponse;
87use axum::routing::get;
88use axum::{Json, Router};
89use prost_reflect::DescriptorPool;
90use std::net::SocketAddr;
91use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
92use tower_http::trace::TraceLayer;
93
94use std::sync::Arc;
95
96use config::{DescriptorSource, ProxyConfig, ScopeConfig};
97use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier};
98use upstream::Upstream;
99
100/// What the proxy's handlers share. Every request extracts its own clone, so
101/// it holds the upstream handle and reference-counted settings only.
102#[derive(Clone, Debug)]
103pub(crate) struct ProxyState<U> {
104    /// The gRPC service transcoded calls and readiness probes go to.
105    pub(crate) upstream: U,
106    /// Headers to forward from HTTP to gRPC.
107    pub(crate) forwarded_headers: Arc<[String]>,
108    /// SSE keep-alive interval (seconds) for server-streaming responses.
109    pub(crate) sse_keep_alive_secs: u64,
110}
111
112/// Universal proxy server.
113pub struct ProxyServer {
114    config: ProxyConfig,
115    /// Optional pre-loaded descriptor pool (for embedded mode).
116    descriptor_pool: Option<DescriptorPool>,
117    /// Optional in-process forward-auth/PDP gate (embedded Tier-2 hook).
118    auth_decider: Option<Arc<dyn AuthDecider>>,
119    /// The traffic the injected decider gates; `transcoded` when unset.
120    auth_decider_scope: Option<ScopeConfig>,
121    /// Optional stateless OIDC surface backing (embedded Tier-2 hook).
122    oidc_backend: Option<Arc<dyn OidcBackend>>,
123    /// Embedder-supplied extra stateless routes (embedded Tier-2 hook).
124    extra_routes: Vec<ExtraRoute>,
125    /// Override for the `/verify` forward-auth path of an injected AuthDecider.
126    verify_path: Option<String>,
127    /// Embedder-supplied JWT verifier, replacing the built-in one.
128    token_verifier: Option<Arc<dyn TokenVerifier>>,
129    /// How the transcoded routes render errors and frame NDJSON streams.
130    transcode: transcode::TranscodeOptions,
131}
132
133impl Default for ProxyServer {
134    /// [`ProxyServer::new`]: every capability off.
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl ProxyServer {
141    /// Create from YAML config file.
142    pub fn from_config(config: ProxyConfig) -> Self {
143        // Earliest point this crate owns: settle the JWT crypto provider here,
144        // long before the first token arrives. A process whose other crates
145        // reach jsonwebtoken before any server exists calls
146        // `install_default_crypto_provider` from `main` instead.
147        #[cfg(feature = "builtin_jwt")]
148        auth::crypto::install_default_crypto_provider();
149
150        Self {
151            config,
152            descriptor_pool: None,
153            auth_decider: None,
154            auth_decider_scope: None,
155            oidc_backend: None,
156            extra_routes: Vec::new(),
157            verify_path: None,
158            token_verifier: None,
159            transcode: transcode::TranscodeOptions::default(),
160        }
161    }
162
163    /// Create from a YAML document: the [`ProxyConfig`] plus the transcoding
164    /// settings it does not hold (`error_details:`,
165    /// `streaming.ndjson_envelope` and `response_headers:`), applied as
166    /// [`with_error_details`], [`with_ndjson_envelope`] and
167    /// [`with_denied_response_headers`] would. A top-level or `streaming:` key
168    /// no setting reads is logged as a warning.
169    ///
170    /// # Errors
171    ///
172    /// Invalid YAML, a [`ProxyConfig`] that fails
173    /// [`validate`](ProxyConfig::validate), an `error_details` route pattern
174    /// that is relative or not a valid glob, or a `response_headers.deny`
175    /// entry that is not a header name.
176    ///
177    /// [`with_error_details`]: Self::with_error_details
178    /// [`with_ndjson_envelope`]: Self::with_ndjson_envelope
179    /// [`with_denied_response_headers`]: Self::with_denied_response_headers
180    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
181        let config = ProxyConfig::from_yaml_str(yaml)?;
182        for key in config::unknown_config_keys(yaml) {
183            tracing::warn!(%key, "unknown config key is ignored");
184        }
185        let settings: config::TranscodeFileConfig = serde_yaml::from_str(yaml)?;
186        let options = settings
187            .options()
188            .map_err(|e| anyhow::anyhow!("invalid transcoding config: {e}"))?;
189        let mut server = Self::from_config(config);
190        server.transcode = options;
191        Ok(server)
192    }
193
194    /// [`from_yaml_str`](Self::from_yaml_str) on the contents of a file.
195    ///
196    /// # Errors
197    ///
198    /// The file cannot be read, or its contents are rejected by
199    /// [`from_yaml_str`](Self::from_yaml_str).
200    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
201        Self::from_yaml_str(&std::fs::read_to_string(path)?)
202    }
203
204    /// The configuration this server was created with.
205    pub fn config(&self) -> &ProxyConfig {
206        &self.config
207    }
208
209    /// A proxy with every capability off: native gRPC and gRPC-Web pass to
210    /// the upstream, every other request to the fallback (`404` by default).
211    /// Turn on what you need with the methods below; each sets the config
212    /// section of the same name, so code and YAML describe one proxy.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use structured_proxy::config::{ConcurrencyConfig, ScopeConfig, Traffic};
218    /// use structured_proxy::ProxyServer;
219    ///
220    /// # fn build() -> anyhow::Result<()> {
221    /// // Your gRPC API, with at most 1000 calls in flight and nothing else.
222    /// let grpc = tonic::service::Routes::default();
223    /// let service = ProxyServer::new()
224    ///     .with_concurrency_limit(ConcurrencyConfig {
225    ///         max_in_flight: 1000,
226    ///         scope: Some(ScopeConfig::traffic([Traffic::Grpc])),
227    ///     })
228    ///     .service(grpc)?;
229    /// # let _ = service;
230    /// # Ok(())
231    /// # }
232    /// # build().unwrap();
233    /// ```
234    pub fn new() -> Self {
235        let mut config = ProxyConfig::default();
236        config.health.enabled = false;
237        config.metrics.enabled = false;
238        Self::from_config(config)
239    }
240
241    /// The remote gRPC upstream (`upstream.default`), for
242    /// [`upstream`](Self::upstream) and [`serve`](Self::serve).
243    pub fn with_upstream_address(mut self, address: impl Into<String>) -> Self {
244        self.config.upstream = Some(config::UpstreamConfig {
245            default: address.into(),
246        });
247        self
248    }
249
250    /// The listener of [`serve`](Self::serve): address, TLS, connection
251    /// limit (`listen:`).
252    pub fn with_listen(mut self, listen: config::ListenConfig) -> Self {
253        self.config.listen = listen;
254        self
255    }
256
257    /// Transcode only these services (`package.Service`) and methods
258    /// (`package.Service/Method`) of the descriptors (`transcode.only`); every
259    /// annotated RPC by default. A name the descriptors do not hold fails the
260    /// build.
261    pub fn with_transcoded_rpcs(
262        mut self,
263        names: impl IntoIterator<Item = impl Into<String>>,
264    ) -> Self {
265        self.config.transcode.only = names.into_iter().map(Into::into).collect();
266        self
267    }
268
269    /// Path aliases rewritten before routing (`aliases:`).
270    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = config::AliasConfig>) -> Self {
271        self.config.aliases = aliases.into_iter().collect();
272        self
273    }
274
275    /// The request headers transcoded calls forward as gRPC metadata
276    /// (`forwarded_headers:`), replacing the default list.
277    pub fn with_forwarded_headers(
278        mut self,
279        names: impl IntoIterator<Item = impl Into<String>>,
280    ) -> Self {
281        self.config.forwarded_headers = names.into_iter().map(Into::into).collect();
282        self
283    }
284
285    /// Health probe endpoints (`health:`).
286    pub fn with_health(mut self, health: config::HealthConfig) -> Self {
287        self.config.health = health;
288        self
289    }
290
291    /// The Prometheus metrics endpoint (`metrics:`).
292    pub fn with_metrics(mut self, metrics: config::MetricsConfig) -> Self {
293        self.config.metrics = metrics;
294        self
295    }
296
297    /// The OpenAPI spec and docs endpoints (`openapi:`).
298    pub fn with_openapi(mut self, openapi: config::OpenApiConfig) -> Self {
299        self.config.openapi = Some(openapi);
300        self
301    }
302
303    /// Static OIDC discovery and JWKS endpoints (`oidc_discovery:`).
304    pub fn with_oidc_discovery(mut self, oidc: config::OidcDiscoveryConfig) -> Self {
305        self.config.oidc_discovery = Some(oidc);
306        self
307    }
308
309    /// The CORS policy (`cors:`).
310    pub fn with_cors(mut self, cors: config::CorsConfig) -> Self {
311        self.config.cors = cors;
312        self
313    }
314
315    /// Translate gRPC-Web to gRPC for an upstream that speaks only gRPC
316    /// (`grpc_web.translate`).
317    pub fn with_grpc_web_translation(mut self, translate: bool) -> Self {
318        self.config.grpc_web.translate = translate;
319        self
320    }
321
322    /// Server-streaming behavior (`streaming:`).
323    pub fn with_streaming(mut self, streaming: config::StreamingConfig) -> Self {
324        self.config.streaming = streaming;
325        self
326    }
327
328    /// Maintenance mode (`maintenance:`).
329    pub fn with_maintenance(mut self, maintenance: config::MaintenanceConfig) -> Self {
330        self.config.maintenance = maintenance;
331        self
332    }
333
334    /// The limit on requests in flight (`concurrency:`).
335    pub fn with_concurrency_limit(mut self, concurrency: config::ConcurrencyConfig) -> Self {
336        self.config.concurrency = Some(concurrency);
337        self
338    }
339
340    /// Rate limits (`shield:`); build the section with [`config::from_yaml`].
341    pub fn with_rate_limits(mut self, shield: config::ShieldConfig) -> Self {
342        self.config.shield = Some(shield);
343        self
344    }
345
346    /// JWT auth, route policies, forward-auth and ext_authz (`auth:`); build
347    /// the section with [`config::from_yaml`].
348    pub fn with_auth(mut self, auth: config::AuthConfig) -> Self {
349        self.config.auth = Some(auth);
350        self
351    }
352
353    /// Create with an embedded descriptor pool (for sid-proxy backward compat).
354    pub fn with_descriptors(mut self, pool: DescriptorPool) -> Self {
355        self.descriptor_pool = Some(pool);
356        self
357    }
358
359    /// Inject an in-process forward-auth / PDP decision (embedded Tier-2 hook).
360    ///
361    /// The decider gates the transcoded requests inline (other traffic with
362    /// [`with_auth_decider_scope`](Self::with_auth_decider_scope)) and also
363    /// backs the `/verify` forward-auth endpoint. Its signature is `axum`-free
364    /// (see [`hooks::AuthDecider`]), so the embedder never names an HTTP
365    /// framework.
366    pub fn with_auth_decider(mut self, decider: Arc<dyn AuthDecider>) -> Self {
367        self.auth_decider = Some(decider);
368        self
369    }
370
371    /// Choose the traffic the injected [`AuthDecider`] gates, `transcoded`
372    /// by default; the `/verify` endpoint is never behind it, since it answers
373    /// for the decider.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use structured_proxy::config::{ScopeConfig, Traffic};
379    /// use structured_proxy::ProxyServer;
380    ///
381    /// # fn build() -> anyhow::Result<()> {
382    /// // Gate native gRPC calls as well as the transcoded ones.
383    /// let server = ProxyServer::from_yaml_str("service:\n  name: demo\n")?
384    ///     .with_auth_decider_scope(ScopeConfig::traffic([Traffic::Transcoded, Traffic::Grpc]));
385    /// # let _ = server;
386    /// # Ok(())
387    /// # }
388    /// # build().unwrap();
389    /// ```
390    pub fn with_auth_decider_scope(mut self, scope: ScopeConfig) -> Self {
391        self.auth_decider_scope = Some(scope);
392        self
393    }
394
395    /// Back the stateless OIDC surface (discovery, JWKS, userinfo) with the
396    /// embedder's key/client metadata (embedded Tier-2 hook).
397    ///
398    /// When set, this supersedes the config-driven static `oidc_discovery`
399    /// routes. See [`hooks::OidcBackend`].
400    pub fn with_oidc_backend(mut self, backend: Arc<dyn OidcBackend>) -> Self {
401        self.oidc_backend = Some(backend);
402        self
403    }
404
405    /// Register extra stateless routes through an `axum`-free adapter (embedded
406    /// Tier-2 hook). See [`hooks::ExtraRoute`] / [`hooks::ExtraRouteHandler`].
407    pub fn with_extra_routes(mut self, routes: impl IntoIterator<Item = ExtraRoute>) -> Self {
408        self.extra_routes.extend(routes);
409        self
410    }
411
412    /// Set the path at which the injected [`AuthDecider`] answers forward-auth
413    /// sub-requests (`/verify`). Independent of any JWT `forward_auth` config, so
414    /// a decider-only embedder can place it without a JWT block.
415    ///
416    /// Resolution order for the path: this override, then
417    /// `auth.forward_auth.path` from config, then the default `/auth/verify`.
418    pub fn with_verify_path(mut self, path: impl Into<String>) -> Self {
419        self.verify_path = Some(path.into());
420        self
421    }
422
423    /// Verify bearer tokens with the embedder's own verifier instead of the
424    /// built-in one (embedded Tier-2 hook).
425    ///
426    /// The JWT middleware keeps everything around the signature check — route
427    /// policies, the roles claim, claim→header forwarding — and takes the
428    /// verdict from [`hooks::TokenVerifier`]. Use this when the built-in crypto
429    /// backend is not the one this binary needs: a validated / FIPS module, an
430    /// HSM, or a verifier the embedder already owns. It is also the way out of
431    /// Cargo's feature unification, which makes `rust_crypto` / `aws_lc_rs` a
432    /// property of the whole dependency graph rather than of one binary.
433    ///
434    /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt`
435    /// then only configures claim forwarding, and any key source in it is
436    /// ignored (with a warning), since the verifier owns its own keys.
437    pub fn with_token_verifier(mut self, verifier: Arc<dyn TokenVerifier>) -> Self {
438        self.token_verifier = Some(verifier);
439        self
440    }
441
442    /// Choose which transcoded routes return the upstream's
443    /// `google.rpc.Status` details in their error bodies. Every route does by
444    /// default; see [`transcode::error::ErrorDetailsPolicy`] to switch them
445    /// off globally or per route.
446    pub fn with_error_details(mut self, policy: transcode::error::ErrorDetailsPolicy) -> Self {
447        self.transcode = self.transcode.with_error_details(policy);
448        self
449    }
450
451    /// Wrap NDJSON stream lines in `{"result"}` / `{"error"}` envelopes; see
452    /// [`transcode::TranscodeOptions::with_ndjson_envelope`].
453    pub fn with_ndjson_envelope(mut self, enabled: bool) -> Self {
454        self.transcode = self.transcode.with_ndjson_envelope(enabled);
455        self
456    }
457
458    /// Keep these upstream response metadata keys off the HTTP responses of
459    /// the transcoded routes; see
460    /// [`transcode::TranscodeOptions::with_denied_response_headers`].
461    pub fn with_denied_response_headers(
462        mut self,
463        names: impl IntoIterator<Item = http::HeaderName>,
464    ) -> Self {
465        self.transcode = self.transcode.with_denied_response_headers(names);
466        self
467    }
468
469    /// Load descriptor pool from configured sources.
470    ///
471    /// Multiple descriptor files are merged into a single pool,
472    /// enabling multi-service proxying from one binary.
473    fn load_descriptors(&self) -> anyhow::Result<DescriptorPool> {
474        if let Some(pool) = &self.descriptor_pool {
475            return Ok(pool.clone());
476        }
477
478        let mut pool = DescriptorPool::new();
479
480        for source in &self.config.descriptors {
481            match source {
482                DescriptorSource::File { file } => {
483                    let bytes = std::fs::read(file).map_err(|e| {
484                        anyhow::anyhow!("Failed to read descriptor file {:?}: {}", file, e)
485                    })?;
486                    pool.decode_file_descriptor_set(bytes.as_slice())
487                        .map_err(|e| {
488                            anyhow::anyhow!("Failed to decode descriptor file {:?}: {}", file, e)
489                        })?;
490                    tracing::info!("Loaded descriptor from {:?}", file);
491                }
492                DescriptorSource::Reflection { reflection } => {
493                    tracing::warn!(
494                        "gRPC reflection client not supported — use descriptor files instead (reflection endpoint: {})",
495                        reflection
496                    );
497                }
498                DescriptorSource::Embedded { bytes } => {
499                    pool.decode_file_descriptor_set(*bytes).map_err(|e| {
500                        anyhow::anyhow!("Failed to decode embedded descriptors: {}", e)
501                    })?;
502                }
503            }
504        }
505
506        Ok(pool)
507    }
508
509    /// The path an injected [`AuthDecider`] answers `/verify` at: the
510    /// `with_verify_path` override, then `auth.forward_auth.path`, then the
511    /// default `/auth/verify`. Only meaningful when a decider is set (the
512    /// override does not apply to config-driven JWT forward-auth).
513    fn decider_verify_path(&self) -> String {
514        self.verify_path.clone().unwrap_or_else(|| {
515            self.config
516                .auth
517                .as_ref()
518                .and_then(|a| a.forward_auth.as_ref())
519                .map(|fa| fa.path.clone())
520                .unwrap_or_else(|| "/auth/verify".to_string())
521        })
522    }
523
524    /// The verify path that is ACTUALLY mounted, or `None` when no verify route
525    /// is mounted. This is what the collision guard and maintenance-exempt list
526    /// must use, since the two mount sites use different paths:
527    /// - an injected decider mounts at [`decider_verify_path`](Self::decider_verify_path)
528    ///   (the `with_verify_path` override applies), whereas
529    /// - config-driven JWT forward-auth mounts `forward_auth.routes()` at
530    ///   `auth.forward_auth.path` (the override does NOT apply, and it mounts
531    ///   only when `auth.mode == "jwt"`, since the endpoint shares the built JWT
532    ///   `Auth`).
533    fn mounted_verify_path(&self) -> Option<String> {
534        if self.auth_decider.is_some() {
535            return Some(self.decider_verify_path());
536        }
537        self.config.auth.as_ref().and_then(|a| {
538            if a.mode != "jwt" {
539                return None;
540            }
541            a.forward_auth
542                .as_ref()
543                .filter(|fa| fa.enabled)
544                .map(|fa| fa.path.clone())
545        })
546    }
547
548    /// Every `(method, path)` route mounted before the verify endpoint, used to
549    /// reject a real collision with a clear error instead of an axum
550    /// duplicate-route panic. `method` is the uppercase HTTP token; same-path
551    /// routes with different methods do NOT collide (the extra-route adapter and
552    /// axum merge them), so the key is the pair, not the path alone.
553    ///
554    /// Must stay exhaustive: health probes, metrics, OpenAPI spec/docs, the OIDC
555    /// surface (injected backend or config-driven static discovery), embedder
556    /// extra routes, and the transcoded REST routes. All built-in surfaces here
557    /// are `GET`.
558    fn reserved_routes(
559        &self,
560        pool: &DescriptorPool,
561        selection: &transcode::RpcSelection,
562    ) -> anyhow::Result<Vec<(String, String)>> {
563        let mut routes = Vec::new();
564        let mut get = |path: String| routes.push(("GET".to_string(), path));
565        if self.config.health.enabled {
566            get(self.config.health.path.clone());
567            get(self.config.health.live_path.clone());
568            get(self.config.health.ready_path.clone());
569            get(self.config.health.startup_path.clone());
570        }
571        if self.config.metrics.enabled {
572            get(self.config.metrics.path.clone());
573        }
574        if let Some(openapi) = self.config.openapi.as_ref().filter(|o| o.enabled) {
575            get(openapi.path.clone());
576            get(openapi.docs_path.clone());
577        }
578        // OIDC: an injected backend supersedes config-driven static discovery.
579        if let Some(backend) = &self.oidc_backend {
580            for doc in backend.metadata_documents() {
581                get(doc.path);
582            }
583            get(backend.jwks().path);
584            get(backend.userinfo_path());
585        } else if let Some(cfg) = &self.config.oidc_discovery {
586            if let Some(oidc) = oidc::Oidc::build(cfg)
587                .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
588            {
589                for path in oidc.paths() {
590                    get(path);
591                }
592            }
593        }
594        for route in &self.extra_routes {
595            routes.push((route.method.as_str().to_string(), route.path.clone()));
596        }
597        routes.extend(transcode::route_paths(
598            pool,
599            &self.config.aliases,
600            selection,
601        ));
602        Ok(routes)
603    }
604
605    /// A lazy channel to the configured upstream address (`upstream.default`),
606    /// the upstream of the standalone proxy. It connects on first use, giving
607    /// up after five seconds.
608    ///
609    /// # Errors
610    ///
611    /// No upstream address is configured, or it is not a valid URI.
612    pub fn upstream(&self) -> anyhow::Result<tonic::transport::Channel> {
613        let Some(upstream) = &self.config.upstream else {
614            anyhow::bail!("no gRPC upstream address is configured (upstream.default)");
615        };
616        Ok(
617            tonic::transport::Channel::from_shared(upstream.default.clone())
618                .map_err(|e| anyhow::anyhow!("invalid gRPC upstream URL: {e}"))?
619                .connect_timeout(std::time::Duration::from_secs(5))
620                .connect_lazy(),
621        )
622    }
623
624    /// The proxy's HTTP routes in front of the configured upstream address
625    /// (see [`upstream`](Self::upstream)), as an axum `Router` to serve or to
626    /// merge into an axum application. It answers HTTP only; use
627    /// [`service`](Self::service) for native gRPC on the same listener, or for
628    /// an upstream in process.
629    ///
630    /// # Errors
631    ///
632    /// No valid upstream address, or a configuration [`service`](Self::service)
633    /// rejects.
634    pub fn router(&self) -> anyhow::Result<Router> {
635        let (router, _, _) = self.routes(self.upstream()?)?;
636        Ok(router)
637    }
638
639    /// The whole proxy as one tower service in front of `upstream`: native
640    /// gRPC requests reach `upstream` unchanged, every other request the
641    /// proxy's routes, whose transcoded calls go to `upstream` too. See
642    /// [`ProxyService`].
643    ///
644    /// `upstream` is any gRPC service ([`upstream::Upstream`]): the embedder's
645    /// own tonic services in process, with no socket between them and the
646    /// proxy, or a remote [`Channel`](tonic::transport::Channel) such as
647    /// [`upstream`](Self::upstream).
648    ///
649    /// # Errors
650    ///
651    /// An invalid configuration (see [`ProxyConfig::validate`]), descriptors
652    /// that cannot be loaded, a route mounted twice, or a malformed auth,
653    /// authz, shield or OIDC section.
654    ///
655    /// # Examples
656    ///
657    /// ```
658    /// use structured_proxy::ProxyServer;
659    ///
660    /// # fn build() -> anyhow::Result<()> {
661    /// let grpc = tonic::service::Routes::default(); // add your services here
662    /// let service = ProxyServer::from_yaml_str("service:\n  name: demo\n")?.service(grpc)?;
663    /// # let _ = service;
664    /// # Ok(())
665    /// # }
666    /// # build().unwrap();
667    /// ```
668    pub fn service<U: Upstream>(&self, upstream: U) -> anyhow::Result<ProxyService<U>> {
669        let (routes, cors, guards) = self.routes(upstream.clone())?;
670        // The routes answer a browser's preflight for gRPC-Web too, so its
671        // call carries the same policy unless the upstream sets its own.
672        let grpc_web_cors = self.config.cors.grpc_web.then_some(cors);
673        Ok(ProxyService::new(
674            upstream,
675            routes,
676            grpc_web_cors,
677            guards,
678            self.config.grpc_web.translate,
679        ))
680    }
681
682    /// Build the axum router with all endpoints, calling `upstream`, the CORS
683    /// policy it answers under, and the guards, for the traffic outside it.
684    fn routes<U: Upstream>(
685        &self,
686        upstream: U,
687    ) -> anyhow::Result<(Router, CorsLayer, Arc<guard::Guards>)> {
688        // Enforce cross-field invariants on the embedded path too, where the
689        // config is built directly instead of through `from_yaml_str`.
690        self.config.validate()?;
691        let pool = self.load_descriptors()?;
692        let selection = transcode::RpcSelection::new(&pool, &self.config.transcode.only)
693            .map_err(|e| anyhow::anyhow!("invalid transcode.only: {e}"))?;
694
695        let service_name = self.config.service.name.clone();
696
697        // The verify path that is actually mounted (branch-correct), if any.
698        let verify_path = self.mounted_verify_path();
699
700        // Validate the WHOLE mounted edge BEFORE any router is built, so a
701        // malformed path (missing leading '/') or a collision (between built-in
702        // routes, the OIDC surface, embedder extra routes, transcoded paths, or
703        // the verify endpoint) is a clear error instead of an axum panic at
704        // `.route`/`.merge`. Collisions are keyed by (method, path): same-path
705        // routes with different methods are legal (they merge), so only a
706        // repeated (method, path) — or any overlap with the verify endpoint,
707        // which answers ALL methods (`*`) — is a real conflict.
708        let mut mounted = self.reserved_routes(&pool, &selection)?;
709        if let Some(vp) = &verify_path {
710            mounted.push(("*".to_string(), vp.clone()));
711        }
712        // Key by NORMALIZED shape, not raw text: axum/matchit treats two dynamic
713        // routes with the same structure but different param names (e.g.
714        // `/v1/x/{a}` and `/v1/x/{b}`) as a conflict, so they must collide here.
715        let mut methods_by_shape: std::collections::HashMap<
716            String,
717            std::collections::HashSet<&str>,
718        > = std::collections::HashMap::new();
719        for (method, path) in &mounted {
720            if !path.starts_with('/') {
721                anyhow::bail!("route path {path:?} must start with '/'");
722            }
723            let methods = methods_by_shape
724                .entry(normalize_route_shape(path))
725                .or_default();
726            // `*` (the verify endpoint) claims every method, so it conflicts with
727            // any other route on the same shape, and vice versa.
728            let conflict = if method == "*" {
729                !methods.is_empty()
730            } else {
731                methods.contains("*") || methods.contains(method.as_str())
732            };
733            if conflict {
734                anyhow::bail!("route path {path:?} is registered by more than one endpoint");
735            }
736            methods.insert(method.as_str());
737        }
738
739        // Keep the actually-configured probe / metrics / verify paths reachable
740        // under maintenance mode. The default exempt list names the default
741        // paths; once those are relocated via config, the relocated paths must
742        // be exempted too, or maintenance would 503 probe and forward-auth
743        // traffic that was intentionally exempt before.
744        let mut maintenance_exempt = self.config.maintenance.exempt_paths.clone();
745        if self.config.health.enabled {
746            maintenance_exempt.push(self.config.health.path.clone());
747            maintenance_exempt.push(self.config.health.live_path.clone());
748            maintenance_exempt.push(self.config.health.ready_path.clone());
749            maintenance_exempt.push(self.config.health.startup_path.clone());
750        }
751        if self.config.metrics.enabled {
752            maintenance_exempt.push(self.config.metrics.path.clone());
753        }
754        if let Some(vp) = &verify_path {
755            maintenance_exempt.push(vp.clone());
756        }
757
758        // A forwarded name gRPC metadata cannot carry would be rejected by a
759        // conforming upstream on every request; refuse it here instead.
760        if let Some(name) = self
761            .config
762            .forwarded_headers
763            .iter()
764            .find(|name| !transcode::metadata::is_grpc_key(name))
765        {
766            anyhow::bail!(
767                "forwarded_headers entry {name:?} is not a gRPC metadata key \
768                 (letters, digits, '_', '-' and '.')"
769            );
770        }
771
772        let state = ProxyState {
773            upstream,
774            forwarded_headers: self.config.forwarded_headers.as_slice().into(),
775            sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs,
776        };
777
778        let cors = self.build_cors()?;
779
780        // Build transcoding routes from descriptor pool.
781        let transcode_routes = transcode::routes_with_options(
782            &pool,
783            &self.config.aliases,
784            &self.transcode.clone().with_selection(selection.clone()),
785        );
786
787        // JWT auth, if configured (auth.mode == "jwt").
788        let auth = match &self.config.auth {
789            Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone())
790                .map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?,
791            None => None,
792        };
793        // Forward-auth verification endpoint, sharing the built Auth.
794        let forward_auth = auth.as_ref().and_then(|built| {
795            auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone())
796        });
797        let guards = self.guards(auth, maintenance_exempt, &mounted)?;
798
799        // Health routes. Paths are configurable; the whole group is skippable.
800        let health_routes = if self.config.health.enabled {
801            let health = &self.config.health;
802            let health_service_name = service_name.clone();
803            Router::new()
804                .route(
805                    &health.path,
806                    get({
807                        let name = health_service_name.clone();
808                        move || async move {
809                            Json(serde_json::json!({
810                                "status": "ok",
811                                "service": name,
812                            }))
813                        }
814                    }),
815                )
816                .route(&health.live_path, get(|| async { StatusCode::OK }))
817                .route(
818                    &health.ready_path,
819                    get(|State(state): State<ProxyState<U>>| async move {
820                        let mut client =
821                            tonic_health::pb::health_client::HealthClient::new(state.upstream);
822                        let check = client.check(tonic_health::pb::HealthCheckRequest {
823                            service: String::new(),
824                        });
825                        // An upstream that does not answer in time is not ready.
826                        match tokio::time::timeout(transcode::UPSTREAM_DEADLINE, check)
827                            .await
828                            .unwrap_or_else(|_| {
829                                Err(tonic::Status::deadline_exceeded("health check timed out"))
830                            }) {
831                            Ok(resp) => {
832                                let status = resp.into_inner().status;
833                                if status
834                                    == tonic_health::pb::health_check_response::ServingStatus::Serving
835                                        as i32
836                                {
837                                    StatusCode::OK
838                                } else {
839                                    StatusCode::SERVICE_UNAVAILABLE
840                                }
841                            }
842                            Err(_) => StatusCode::SERVICE_UNAVAILABLE,
843                        }
844                    }),
845                )
846                .route(&health.startup_path, get(|| async { StatusCode::OK }))
847        } else {
848            Router::new()
849        };
850
851        // Metrics route. Path is configurable; the endpoint is skippable.
852        let metrics_routes = if self.config.metrics.enabled {
853            Router::new().route(
854                &self.config.metrics.path,
855                get(|| async {
856                    let encoder = prometheus::TextEncoder::new();
857                    let metric_families = prometheus::default_registry().gather();
858                    match encoder.encode_to_string(&metric_families) {
859                        Ok(text) => (
860                            StatusCode::OK,
861                            [(
862                                axum::http::header::CONTENT_TYPE,
863                                "text/plain; version=0.0.4; charset=utf-8",
864                            )],
865                            text,
866                        )
867                            .into_response(),
868                        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
869                    }
870                }),
871            )
872        } else {
873            Router::new()
874        };
875
876        // OpenAPI + docs routes (if enabled).
877        let openapi_routes = self.build_openapi_routes(&pool, &selection);
878
879        // OIDC routes (public, like the health endpoints). An injected
880        // OidcBackend supersedes the config-driven static discovery: the proxy
881        // hosts the HTTP surface, the embedder supplies the content.
882        let oidc_routes = match &self.oidc_backend {
883            Some(backend) => embed::oidc_backend_routes(backend.clone()),
884            None => match &self.config.oidc_discovery {
885                Some(cfg) => oidc::Oidc::build(cfg)
886                    .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
887                    .map(|o| o.routes())
888                    .unwrap_or_default(),
889                None => Router::new(),
890            },
891        };
892
893        let endpoints = Router::new()
894            .merge(health_routes)
895            .merge(metrics_routes)
896            .merge(openapi_routes)
897            .merge(oidc_routes)
898            .merge(embed::extra_routes_router(&self.extra_routes));
899
900        // Forward-auth `/verify` endpoint. An injected AuthDecider owns it when
901        // present (in-process PDP); otherwise the config-driven JWT ForwardAuth
902        // backs it. Its path was validated above.
903        let verify = if let Some(decider) = &self.auth_decider {
904            let decider = decider.clone();
905            Router::new().route(
906                &self.decider_verify_path(),
907                axum::routing::any(move |req: axum::extract::Request| {
908                    let decider = decider.clone();
909                    async move { embed::verify_via_decider(decider, req).await }
910                }),
911            )
912        } else if let Some(forward_auth) = &forward_auth {
913            forward_auth.routes()
914        } else {
915            Router::new()
916        };
917
918        // Each class of traffic behind the guards that cover it, so a request
919        // runs only the guards of its own class. A path no route answers
920        // reaches the plain 404 (or the fallback's own guards).
921        let router = Router::new()
922            .merge(guards.router(transcode_routes, guard::Class::Transcoded))
923            .merge(guards.router(endpoints, guard::Class::Endpoints))
924            .merge(guards.router(verify, guard::Class::Verify))
925            .layer(TraceLayer::new_for_http());
926        // Outermost: wraps every enforcement layer so short-circuited
927        // responses keep CORS headers, and answers preflight before auth.
928        let router = cors::layer(router, cors.clone()).with_state(state);
929
930        Ok((router, cors, Arc::new(guards)))
931    }
932
933    /// The guards the configuration and the hooks turn on, each with its
934    /// scope; `maintenance_exempt` lists the paths maintenance mode leaves
935    /// reachable, `mounted` the `(method, path)` of every route.
936    ///
937    /// # Errors
938    ///
939    /// A malformed shield, authz or concurrency section, or a scope that
940    /// covers no traffic, names an invalid path glob or an invalid method.
941    fn guards(
942        &self,
943        auth: Option<Arc<auth::Auth>>,
944        maintenance_exempt: Vec<String>,
945        mounted: &[(String, String)],
946    ) -> anyhow::Result<guard::Guards> {
947        use config::Traffic::{Endpoints, Grpc, Transcoded};
948        // `*` marks a route that answers every method, not a method.
949        let routed: Vec<http::Method> = mounted
950            .iter()
951            .filter(|(method, _)| method != "*")
952            .filter_map(|(method, _)| http::Method::from_bytes(method.as_bytes()).ok())
953            .collect();
954        let scope = |config: Option<&ScopeConfig>, default: &[config::Traffic], what: &str| {
955            guard::Scope::compile(config, default, what, &routed).map_err(anyhow::Error::msg)
956        };
957        let mut guards = guard::Guards::default();
958        // Mounted only while maintenance is on, so normal traffic pays nothing
959        // for it.
960        let maintenance = &self.config.maintenance;
961        if maintenance.enabled {
962            guards.maintenance = Some((
963                Arc::new(guard::Maintenance {
964                    exempt: maintenance_exempt,
965                    message: maintenance.message.clone(),
966                }),
967                scope(
968                    maintenance.scope.as_ref(),
969                    &[Transcoded, Endpoints],
970                    "maintenance",
971                )?,
972            ));
973        }
974        if let Some(cfg) = &self.config.concurrency {
975            guards.concurrency = Some((
976                guard::Concurrency::build(cfg).map_err(anyhow::Error::msg)?,
977                // The proxy's own endpoints stay out by default: a health
978                // probe refused under load gets a busy instance restarted.
979                scope(cfg.scope.as_ref(), &[Transcoded, Grpc], "concurrency")?,
980            ));
981        }
982        if let Some(cfg) = &self.config.shield {
983            if let Some(shield) = shield::Shield::build(cfg)
984                .map_err(|e| anyhow::anyhow!("invalid shield config: {e}"))?
985            {
986                guards.shield = Some((
987                    shield,
988                    scope(cfg.scope.as_ref(), &[Transcoded, Endpoints], "shield")?,
989                ));
990            }
991        }
992        let auth_config = self.config.auth.as_ref();
993        if let Some(auth) = auth {
994            guards.auth = Some((
995                auth,
996                scope(
997                    auth_config.and_then(|a| a.scope.as_ref()),
998                    &[Transcoded, Endpoints],
999                    "auth",
1000                )?,
1001            ));
1002        }
1003        if let Some(cfg) = auth_config.and_then(|a| a.authz.as_ref()) {
1004            if let Some(authz) = auth::authz::Authz::build(cfg)
1005                .map_err(|e| anyhow::anyhow!("invalid authz config: {e}"))?
1006            {
1007                guards.authz = Some((
1008                    authz,
1009                    scope(cfg.scope.as_ref(), &[Transcoded], "auth.authz")?,
1010                ));
1011            }
1012        }
1013        if let Some(decider) = &self.auth_decider {
1014            guards.decider = Some((
1015                decider.clone(),
1016                scope(
1017                    self.auth_decider_scope.as_ref(),
1018                    &[Transcoded],
1019                    "auth_decider",
1020                )?,
1021            ));
1022        }
1023        Ok(guards)
1024    }
1025
1026    fn build_openapi_routes<S>(
1027        &self,
1028        pool: &DescriptorPool,
1029        selection: &transcode::RpcSelection,
1030    ) -> Router<S>
1031    where
1032        S: Clone + Send + Sync + 'static,
1033    {
1034        let openapi_config = match &self.config.openapi {
1035            Some(cfg) if cfg.enabled => cfg,
1036            _ => return Router::new(),
1037        };
1038
1039        let spec = openapi::generate(pool, openapi_config, &self.config.aliases, selection);
1040        let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
1041        let openapi_path = openapi_config.path.clone();
1042        let docs_path = openapi_config.docs_path.clone();
1043        let title = openapi_config
1044            .title
1045            .clone()
1046            .unwrap_or_else(|| self.config.service.name.clone());
1047        let openapi_path_for_docs = openapi_path.clone();
1048
1049        tracing::info!("OpenAPI spec at {}, docs at {}", openapi_path, docs_path,);
1050
1051        Router::new()
1052            .route(
1053                &openapi_path,
1054                get(move || async move {
1055                    (
1056                        StatusCode::OK,
1057                        [(
1058                            axum::http::header::CONTENT_TYPE,
1059                            "application/json; charset=utf-8",
1060                        )],
1061                        spec_json,
1062                    )
1063                }),
1064            )
1065            .route(
1066                &docs_path,
1067                get(move || async move {
1068                    let html = openapi::docs_html(&openapi_path_for_docs, &title);
1069                    (
1070                        StatusCode::OK,
1071                        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
1072                        html,
1073                    )
1074                }),
1075            )
1076    }
1077
1078    /// The CORS policy of `cors:`.
1079    ///
1080    /// # Errors
1081    ///
1082    /// An origin that is not a header value, or an `expose_headers` entry that
1083    /// is not a header name: dropping either would quietly narrow the policy.
1084    fn build_cors(&self) -> anyhow::Result<CorsLayer> {
1085        let config = &self.config.cors;
1086        // Checked in both modes, so a typo fails the same way whether or not
1087        // origins are set.
1088        let configured = config
1089            .expose_headers
1090            .iter()
1091            .map(|name| {
1092                http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
1093                    anyhow::anyhow!("cors.expose_headers entry {name:?} is not a header name")
1094                })
1095            })
1096            .collect::<anyhow::Result<Vec<_>>>()?;
1097        let layer = if config.origins.is_empty() {
1098            tracing::warn!("CORS origins not set — using permissive CORS (dev mode)");
1099            // Exposes every header already.
1100            CorsLayer::permissive()
1101        } else {
1102            let origins = config
1103                .origins
1104                .iter()
1105                .map(|origin| {
1106                    http::HeaderValue::from_str(origin)
1107                        .map_err(|_| anyhow::anyhow!("cors.origins entry {origin:?} is not valid"))
1108                })
1109                .collect::<anyhow::Result<Vec<_>>>()?;
1110            let exposed = [
1111                // What a gRPC-Web client reads its status and details from.
1112                http::HeaderName::from_static("grpc-status"),
1113                http::HeaderName::from_static("grpc-message"),
1114                http::HeaderName::from_static("grpc-status-details-bin"),
1115                // Let browser clients read the rate-limit budget and back off.
1116                http::HeaderName::from_static("ratelimit-limit"),
1117                http::HeaderName::from_static("ratelimit-remaining"),
1118                http::HeaderName::from_static("ratelimit-reset"),
1119                http::HeaderName::from_static("retry-after"),
1120            ]
1121            .into_iter()
1122            .chain(configured);
1123            // With credentials the Fetch standard (§3.2.5) forbids `*` for
1124            // methods and headers, so the preflight's own request is echoed
1125            // back instead: what the browser asked for, from an allowed origin.
1126            CorsLayer::new()
1127                .allow_origin(AllowOrigin::list(origins))
1128                .allow_methods(AllowMethods::mirror_request())
1129                .allow_headers(AllowHeaders::mirror_request())
1130                .allow_credentials(true)
1131                .expose_headers(exposed.collect::<Vec<_>>())
1132        };
1133        Ok(match config.max_age_secs {
1134            Some(secs) => layer.max_age(std::time::Duration::from_secs(secs)),
1135            None => layer,
1136        })
1137    }
1138
1139    /// The [`ServeOptions`] of `listen:`: TLS from `listen.tls` (mTLS with its
1140    /// `client_ca_file`), the `listen.max_connections` cap and the connection
1141    /// timeouts, for [`serve_with`] on a listener of your own.
1142    ///
1143    /// # Errors
1144    ///
1145    /// A `max_connections`, `header_read_timeout_secs` or
1146    /// `tls.handshake_timeout_secs` of zero, TLS files that cannot be loaded,
1147    /// or no rustls crypto provider for TLS.
1148    pub fn serve_options(&self) -> anyhow::Result<ServeOptions> {
1149        use std::time::Duration;
1150        let listen = &self.config.listen;
1151        anyhow::ensure!(
1152            listen.header_read_timeout_secs > 0,
1153            "listen.header_read_timeout_secs must be at least 1"
1154        );
1155        let mut options = ServeOptions::new()
1156            .idle_timeout(
1157                (listen.idle_timeout_secs > 0)
1158                    .then(|| Duration::from_secs(listen.idle_timeout_secs)),
1159            )
1160            .header_read_timeout(Duration::from_secs(listen.header_read_timeout_secs))
1161            .drain_timeout(
1162                (listen.drain_timeout_secs > 0)
1163                    .then(|| Duration::from_secs(listen.drain_timeout_secs)),
1164            );
1165        if let Some(max) = listen.max_connections {
1166            anyhow::ensure!(max > 0, "listen.max_connections must be at least 1");
1167            options = options.max_connections(max);
1168        }
1169        if let Some(tls) = &listen.tls {
1170            anyhow::ensure!(
1171                tls.handshake_timeout_secs > 0,
1172                "listen.tls.handshake_timeout_secs must be at least 1"
1173            );
1174            options = options
1175                .tls(
1176                    tls::server_config(tls)
1177                        .map_err(|e| anyhow::anyhow!("invalid listen.tls: {e}"))?,
1178                )
1179                .tls_handshake_timeout(Duration::from_secs(tls.handshake_timeout_secs));
1180        }
1181        Ok(options)
1182    }
1183
1184    /// Serve the proxy on the configured listen address in front of the
1185    /// configured upstream address: REST and native gRPC on one port, with
1186    /// the TLS and connection limit of `listen:` (see
1187    /// [`serve_options`](Self::serve_options) and [`serve_with`]).
1188    ///
1189    /// # Errors
1190    ///
1191    /// What [`upstream`](Self::upstream), [`service`](Self::service) and
1192    /// [`serve_options`](Self::serve_options) reject, an invalid listen
1193    /// address, or a listener that fails.
1194    pub async fn serve(&self) -> anyhow::Result<()> {
1195        self.serve_with_shutdown(std::future::pending()).await
1196    }
1197
1198    /// [`serve`](Self::serve) until `signal` completes, then shut down with
1199    /// the drain of `listen.drain_timeout_secs` (see [`serve_with_shutdown`]).
1200    ///
1201    /// # Errors
1202    ///
1203    /// What [`serve`](Self::serve) returns.
1204    ///
1205    /// # Examples
1206    ///
1207    /// ```no_run
1208    /// # async fn run(server: structured_proxy::ProxyServer) -> anyhow::Result<()> {
1209    /// server
1210    ///     .serve_with_shutdown(async {
1211    ///         tokio::signal::ctrl_c().await.ok();
1212    ///     })
1213    ///     .await
1214    /// # }
1215    /// ```
1216    pub async fn serve_with_shutdown(
1217        &self,
1218        signal: impl std::future::Future<Output = ()>,
1219    ) -> anyhow::Result<()> {
1220        let service = self.service(self.upstream()?)?;
1221        let options = self.serve_options()?;
1222        let addr: SocketAddr = self.config.listen.http.parse()?;
1223        let listener = tokio::net::TcpListener::bind(addr).await?;
1224
1225        tracing::info!(
1226            tls = self.config.listen.tls.is_some(),
1227            "{} listening on {}",
1228            self.config.service.name,
1229            addr
1230        );
1231        serve_with_shutdown(listener, service, options, signal).await?;
1232        tracing::info!("{} stopped", self.config.service.name);
1233        Ok(())
1234    }
1235}
1236
1237/// Canonical shape of an axum route path for collision detection: every dynamic
1238/// segment (`{name}` capture or `{*name}` wildcard) is replaced by a
1239/// name-independent placeholder, so structurally identical routes that differ
1240/// only in parameter name (which axum/matchit rejects as a conflict) map to the
1241/// same key. Literal segments are unchanged.
1242fn normalize_route_shape(path: &str) -> String {
1243    path.split('/')
1244        .map(|seg| {
1245            if seg.starts_with("{*") && seg.ends_with('}') {
1246                "{*}"
1247            } else if seg.starts_with('{') && seg.ends_with('}') {
1248                "{}"
1249            } else {
1250                seg
1251            }
1252        })
1253        .collect::<Vec<_>>()
1254        .join("/")
1255}
1256
1257/// A [`ProxyState`] for tests whose routers never call the upstream: a lazy
1258/// channel to a port nothing listens on.
1259#[cfg(test)]
1260pub(crate) fn test_state() -> ProxyState<tonic::transport::Channel> {
1261    ProxyState {
1262        upstream: tonic::transport::Channel::from_static("http://127.0.0.1:1")
1263            .connect_timeout(std::time::Duration::from_millis(100))
1264            .connect_lazy(),
1265        forwarded_headers: Arc::from([]),
1266        sse_keep_alive_secs: 15,
1267    }
1268}
1269
1270#[cfg(test)]
1271mod tests;