Skip to main content

structured_proxy/
lib.rs

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