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;