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