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