Skip to main content

structured_proxy/
config.rs

1//! YAML-based proxy configuration.
2//!
3//! All product-specific behavior is driven by config, not code.
4//! Same binary, different YAML = different product proxy.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// Top-level proxy configuration (loaded from YAML).
10///
11/// This and the wiring structs below (`UpstreamConfig`, `ListenConfig`,
12/// `ServiceConfig`, `DescriptorSource`) are intentionally NOT
13/// `#[non_exhaustive]`: embedding consumers build them programmatically with
14/// runtime values. The leaf auth/shield/oidc config structs are
15/// `#[non_exhaustive]` instead, since those are deserialized, not hand-built.
16#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18    /// The remote gRPC upstream. Required by the standalone proxy and by
19    /// [`ProxyServer::upstream`](crate::ProxyServer::upstream); an embedder
20    /// whose upstream is in process
21    /// ([`ProxyServer::service`](crate::ProxyServer::service)) leaves it out.
22    #[serde(default)]
23    pub upstream: Option<UpstreamConfig>,
24
25    /// Proto descriptor sources.
26    #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
27    pub descriptors: Vec<DescriptorSource>,
28
29    /// Listen addresses.
30    #[serde(default)]
31    pub listen: ListenConfig,
32
33    /// Service identity (for health endpoint, metrics namespace).
34    #[serde(default)]
35    pub service: ServiceConfig,
36
37    /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
38    #[serde(default)]
39    pub aliases: Vec<AliasConfig>,
40
41    /// OpenAPI generation.
42    #[serde(default)]
43    pub openapi: Option<OpenApiConfig>,
44
45    /// Auth configuration (JWT, forward auth, AuthZ).
46    #[serde(default)]
47    pub auth: Option<AuthConfig>,
48
49    /// Rate limiting (Shield).
50    #[serde(default)]
51    pub shield: Option<ShieldConfig>,
52
53    /// OIDC discovery (optional — for IdP proxies).
54    #[serde(default)]
55    pub oidc_discovery: Option<OidcDiscoveryConfig>,
56
57    /// Health-probe endpoints (paths configurable; can be disabled).
58    #[serde(default)]
59    pub health: HealthConfig,
60
61    /// Prometheus metrics endpoint (path configurable; can be disabled).
62    #[serde(default)]
63    pub metrics: MetricsConfig,
64
65    /// Maintenance mode.
66    #[serde(default)]
67    pub maintenance: MaintenanceConfig,
68
69    /// CORS configuration.
70    #[serde(default)]
71    pub cors: CorsConfig,
72
73    /// Logging.
74    #[serde(default)]
75    pub logging: LoggingConfig,
76
77    /// Metrics endpoint classification (path patterns → class labels).
78    #[serde(default)]
79    pub metrics_classes: Vec<MetricsClassConfig>,
80
81    /// Headers to forward from HTTP to gRPC metadata.
82    #[serde(default = "default_forwarded_headers")]
83    pub forwarded_headers: Vec<String>,
84
85    /// Server-streaming response behavior.
86    #[serde(default)]
87    pub streaming: StreamingConfig,
88
89    /// Request concurrency limit.
90    #[serde(default)]
91    pub concurrency: Option<ConcurrencyConfig>,
92
93    /// How gRPC-Web calls reach the upstream.
94    #[serde(default)]
95    pub grpc_web: GrpcWebConfig,
96
97    /// Which annotated RPCs are transcoded.
98    #[serde(default)]
99    pub transcode: TranscodeConfig,
100}
101
102impl Default for ProxyConfig {
103    /// The configuration of an empty YAML file: health and metrics endpoints
104    /// on, everything else off.
105    fn default() -> Self {
106        Self {
107            upstream: None,
108            descriptors: Vec::new(),
109            listen: ListenConfig::default(),
110            service: ServiceConfig::default(),
111            aliases: Vec::new(),
112            openapi: None,
113            auth: None,
114            shield: None,
115            oidc_discovery: None,
116            health: HealthConfig::default(),
117            metrics: MetricsConfig::default(),
118            maintenance: MaintenanceConfig::default(),
119            cors: CorsConfig::default(),
120            logging: LoggingConfig::default(),
121            metrics_classes: Vec::new(),
122            forwarded_headers: default_forwarded_headers(),
123            streaming: StreamingConfig::default(),
124            concurrency: None,
125            grpc_web: GrpcWebConfig::default(),
126            transcode: TranscodeConfig::default(),
127        }
128    }
129}
130
131/// Which annotated RPCs are transcoded.
132///
133/// ```yaml
134/// transcode:
135///   only: ["acme.v1.Orders", "acme.v1.Users/GetUser"]
136/// ```
137#[derive(Debug, Clone, Default, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct TranscodeConfig {
140    /// Services (`package.Service`) and single methods
141    /// (`package.Service/Method`) whose `google.api.http` rules become REST
142    /// routes; empty transcodes every annotated RPC. A name the descriptors do
143    /// not hold stops the proxy at startup.
144    #[serde(default)]
145    pub only: Vec<String>,
146}
147
148/// One config section from YAML, the way the config file reads it: for the
149/// sections an embedder hands to a builder method, such as
150/// [`ProxyServer::with_rate_limits`](crate::ProxyServer::with_rate_limits).
151///
152/// # Errors
153///
154/// YAML that is not that section.
155///
156/// # Examples
157///
158/// ```
159/// use structured_proxy::config::{self, ShieldConfig};
160///
161/// let shield: ShieldConfig = config::from_yaml(
162///     "enabled: true\nprofiles:\n  anon: { rate: \"60/min\" }\nrules:\n  - pattern: \"/**\"\n    key: { type: ip }\n    profile: anon\n",
163/// )
164/// .unwrap();
165/// assert!(shield.enabled);
166/// ```
167pub fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> anyhow::Result<T> {
168    Ok(serde_yaml::from_str(yaml)?)
169}
170
171/// How gRPC-Web calls reach the upstream.
172#[derive(Debug, Clone, Default, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct GrpcWebConfig {
175    /// Translate gRPC-Web (binary and text, over HTTP/1.1 too) to gRPC for an
176    /// upstream that speaks only gRPC. Off: gRPC-Web passes through, for an
177    /// upstream that translates it itself. Needs the proxy's CORS policy on
178    /// gRPC-Web (`cors.grpc_web`), since the upstream then cannot answer a
179    /// browser's preflight.
180    #[serde(default)]
181    pub translate: bool,
182}
183
184fn default_forwarded_headers() -> Vec<String> {
185    vec![
186        "authorization".into(),
187        "dpop".into(),
188        "x-request-id".into(),
189        "x-forwarded-for".into(),
190        "x-forwarded-proto".into(),
191        "x-real-ip".into(),
192        "accept-language".into(),
193        "user-agent".into(),
194        "idempotency-key".into(),
195    ]
196}
197
198/// Server-streaming response behavior.
199///
200/// Server-streaming RPCs are exposed as NDJSON by default and as Server-Sent
201/// Events when the client sends `Accept: text/event-stream`. The keep-alive
202/// interval applies only to the SSE path.
203#[derive(Debug, Clone, Deserialize)]
204pub struct StreamingConfig {
205    /// SSE keep-alive interval in seconds. Comment frames are emitted on idle
206    /// streams to keep intermediaries (load balancers, nginx) from closing the
207    /// connection on read timeout. Default: 15.
208    #[serde(default = "default_sse_keep_alive_secs")]
209    pub sse_keep_alive_secs: u64,
210}
211
212fn default_sse_keep_alive_secs() -> u64 {
213    15
214}
215
216impl Default for StreamingConfig {
217    fn default() -> Self {
218        Self {
219            sse_keep_alive_secs: default_sse_keep_alive_secs(),
220        }
221    }
222}
223
224/// Transcoding settings read from the same YAML document as [`ProxyConfig`] by
225/// [`ProxyServer::from_yaml_str`](crate::ProxyServer::from_yaml_str) and
226/// [`ProxyServer::from_file`](crate::ProxyServer::from_file):
227///
228/// ```yaml
229/// error_details:
230///   enabled: true
231///   opaque: false
232///   routes:
233///     - pattern: "/v1/admin/**"
234///       enabled: false
235///     - pattern: "/v1/partner/**"
236///       opaque: true
237/// streaming:
238///   ndjson_envelope: true
239/// response_headers:
240///   deny: ["x-debug-trace"]
241/// ```
242///
243/// They live outside [`ProxyConfig`] so that embedders who build it as a
244/// struct literal are not affected.
245#[derive(Debug, Default, Deserialize)]
246pub(crate) struct TranscodeFileConfig {
247    #[serde(default)]
248    error_details: Option<ErrorDetailsFileConfig>,
249    #[serde(default)]
250    streaming: StreamingFileConfig,
251    #[serde(default)]
252    response_headers: Option<ResponseHeadersFileConfig>,
253}
254
255/// `response_headers:`. A typo here would silently let an internal header
256/// reach clients, so unknown keys are rejected.
257#[derive(Debug, Deserialize)]
258#[serde(deny_unknown_fields)]
259struct ResponseHeadersFileConfig {
260    /// Upstream response metadata keys kept off the HTTP response.
261    #[serde(default)]
262    deny: Vec<String>,
263}
264
265/// `error_details:`. A typo here would silently change what clients learn
266/// about server-side failures, so unknown keys are rejected.
267#[derive(Debug, Deserialize)]
268#[serde(deny_unknown_fields)]
269struct ErrorDetailsFileConfig {
270    /// Default for routes no rule matches.
271    #[serde(default = "default_true")]
272    enabled: bool,
273    /// Default for routes no rule matches: whether details of types no
274    /// descriptor describes go to `opaqueDetails` instead of being withheld.
275    #[serde(default)]
276    opaque: bool,
277    /// Per-route overrides; for each switch, the first matching rule that
278    /// sets it wins.
279    #[serde(default)]
280    routes: Vec<ErrorDetailsRouteFileConfig>,
281}
282
283/// A rule sets `enabled`, `opaque` or both; one setting neither is rejected.
284#[derive(Debug, Deserialize)]
285#[serde(deny_unknown_fields)]
286struct ErrorDetailsRouteFileConfig {
287    pattern: String,
288    #[serde(default)]
289    enabled: Option<bool>,
290    #[serde(default)]
291    opaque: Option<bool>,
292}
293
294/// The `streaming:` keys [`StreamingConfig`] does not hold. Both read the same
295/// section, so neither can reject the other's keys; [`unknown_config_keys`]
296/// reports the keys neither reads.
297#[derive(Debug, Default, Deserialize)]
298struct StreamingFileConfig {
299    #[serde(default)]
300    ndjson_envelope: bool,
301}
302
303/// Every top-level key a config file may use: the [`ProxyConfig`] fields, the
304/// transcoding settings kept outside it, and `runtime`, which the standalone
305/// binary reads to build its async runtime (an embedder brings its own).
306pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
307    "upstream",
308    "descriptors",
309    "listen",
310    "service",
311    "aliases",
312    "openapi",
313    "auth",
314    "shield",
315    "oidc_discovery",
316    "health",
317    "metrics",
318    "maintenance",
319    "cors",
320    "logging",
321    "metrics_classes",
322    "forwarded_headers",
323    "streaming",
324    "error_details",
325    "response_headers",
326    "runtime",
327    "concurrency",
328    "grpc_web",
329    "transcode",
330];
331
332/// Every `streaming:` key: the [`StreamingConfig`] fields plus the ones
333/// [`StreamingFileConfig`] reads.
334pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
335
336/// The keys of `yaml` that no setting reads, typically typos (`error_detail:`
337/// for `error_details:`) that would otherwise leave a default silently in
338/// force. They are reported, not rejected, so a file that loaded before keeps
339/// loading.
340pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
341    let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
342        return Vec::new();
343    };
344    let mut unknown: Vec<String> = map
345        .keys()
346        .filter_map(|key| key.as_str())
347        .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
348        .map(str::to_owned)
349        .collect();
350    // `streaming:` is split between StreamingConfig and StreamingFileConfig,
351    // so neither can deny unknown fields; its keys are checked here instead.
352    if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
353        unknown.extend(
354            streaming
355                .keys()
356                .filter_map(|key| key.as_str())
357                .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
358                .map(|key| format!("streaming.{key}")),
359        );
360    }
361    unknown
362}
363
364impl TranscodeFileConfig {
365    /// Compile into the options the transcoded routes are built with.
366    ///
367    /// # Errors
368    ///
369    /// An `error_details` route pattern that is relative or not a valid glob,
370    /// a route rule that sets neither `enabled` nor `opaque`, or a
371    /// `response_headers.deny` entry that is not a header name.
372    pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
373        use crate::transcode::error::ErrorDetailsPolicy;
374        let mut options = crate::transcode::TranscodeOptions::default()
375            .with_ndjson_envelope(self.streaming.ndjson_envelope);
376        if let Some(cfg) = &self.response_headers {
377            let deny = cfg
378                .deny
379                .iter()
380                .map(|name| {
381                    http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
382                        format!("response_headers.deny entry {name:?} is not a header name")
383                    })
384                })
385                .collect::<Result<Vec<_>, _>>()?;
386            options = options.with_denied_response_headers(deny);
387        }
388        if let Some(cfg) = &self.error_details {
389            let base = if cfg.enabled {
390                ErrorDetailsPolicy::default()
391            } else {
392                ErrorDetailsPolicy::disabled()
393            }
394            .with_opaque_details(cfg.opaque);
395            let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
396                // A rule without a switch would match routes and change
397                // nothing, which is a config mistake, not an intent.
398                if rule.enabled.is_none() && rule.opaque.is_none() {
399                    return Err(format!(
400                        "error details route {:?} sets neither enabled nor opaque",
401                        rule.pattern
402                    ));
403                }
404                policy.rule(&rule.pattern, rule.enabled, rule.opaque)
405            })?;
406            options = options.with_error_details(policy);
407        }
408        Ok(options)
409    }
410}
411
412/// Upstream gRPC service configuration.
413#[derive(Debug, Clone, Deserialize)]
414pub struct UpstreamConfig {
415    /// gRPC upstream address (e.g., "http://localhost:4180").
416    pub default: String,
417}
418
419/// Descriptor loading source.
420#[derive(Debug, Clone)]
421pub enum DescriptorSource {
422    /// Pre-compiled descriptor file.
423    File { file: PathBuf },
424    /// gRPC server reflection (development mode).
425    Reflection { reflection: String },
426    /// Embedded bytes (set programmatically, not from YAML).
427    Embedded { bytes: &'static [u8] },
428}
429
430/// Helper for YAML deserialization (only File and Reflection variants).
431#[derive(Debug, Clone, Deserialize)]
432#[serde(untagged)]
433enum DescriptorSourceYaml {
434    File { file: PathBuf },
435    Reflection { reflection: String },
436}
437
438impl From<DescriptorSourceYaml> for DescriptorSource {
439    fn from(yaml: DescriptorSourceYaml) -> Self {
440        match yaml {
441            DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
442            DescriptorSourceYaml::Reflection { reflection } => {
443                DescriptorSource::Reflection { reflection }
444            }
445        }
446    }
447}
448
449fn deserialize_descriptor_sources<'de, D>(
450    deserializer: D,
451) -> std::result::Result<Vec<DescriptorSource>, D::Error>
452where
453    D: serde::Deserializer<'de>,
454{
455    let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
456    Ok(yaml_sources.into_iter().map(Into::into).collect())
457}
458
459/// The listener [`ProxyServer::serve`](crate::ProxyServer::serve) runs.
460#[derive(Debug, Clone, Deserialize)]
461#[serde(deny_unknown_fields)]
462pub struct ListenConfig {
463    /// Listen address (default: "0.0.0.0:8080").
464    #[serde(default = "default_http_listen")]
465    pub http: String,
466    /// Most connections served at once; past it the next connection is
467    /// accepted when one closes. Unset: no limit.
468    #[serde(default)]
469    pub max_connections: Option<usize>,
470    /// TLS on the listener, mTLS with `client_ca_file`. Unset: cleartext.
471    #[serde(default)]
472    pub tls: Option<ListenTlsConfig>,
473    /// Seconds a connection may go without a request in flight before it is
474    /// closed (HTTP/2 gets a GOAWAY), so idle clients do not hold
475    /// `max_connections` slots. 0 keeps idle connections open. Default: 60.
476    #[serde(default = "default_idle_timeout_secs")]
477    pub idle_timeout_secs: u64,
478    /// Seconds an HTTP/1.1 client has to send a request's headers. At least
479    /// 1. Default: 30.
480    #[serde(default = "default_header_read_timeout_secs")]
481    pub header_read_timeout_secs: u64,
482}
483
484fn default_idle_timeout_secs() -> u64 {
485    60
486}
487
488fn default_header_read_timeout_secs() -> u64 {
489    30
490}
491
492fn default_tls_handshake_timeout_secs() -> u64 {
493    10
494}
495
496/// TLS for the listener.
497///
498/// ```yaml
499/// tls:
500///   cert_file: /etc/proxy/tls.crt      # PEM chain, leaf first
501///   key_file: /etc/proxy/tls.key       # PEM private key
502///   client_ca_file: /etc/proxy/ca.crt  # optional: verify client certificates
503///   client_auth: required              # or `optional`
504/// ```
505#[derive(Debug, Clone, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct ListenTlsConfig {
508    /// The server certificate chain, PEM, leaf first.
509    pub cert_file: PathBuf,
510    /// The server private key, PEM (PKCS#8, PKCS#1 or SEC1).
511    pub key_file: PathBuf,
512    /// CA certificates, PEM, that client certificates are verified against.
513    /// Unset: clients present none.
514    #[serde(default)]
515    pub client_ca_file: Option<PathBuf>,
516    /// Whether a client must present a certificate; `required` when
517    /// `client_ca_file` is set and this is not. Setting it without
518    /// `client_ca_file` is an error: nothing could verify the certificate.
519    #[serde(default)]
520    pub client_auth: Option<ClientAuth>,
521    /// Seconds a client has to finish the TLS handshake. At least 1.
522    /// Default: 10.
523    #[serde(default = "default_tls_handshake_timeout_secs")]
524    pub handshake_timeout_secs: u64,
525}
526
527/// Whether a TLS client must present a certificate.
528#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
529#[serde(rename_all = "snake_case")]
530pub enum ClientAuth {
531    /// A client without a valid certificate is refused in the handshake.
532    #[default]
533    Required,
534    /// A certificate is verified when presented; a client may present none.
535    Optional,
536}
537
538fn default_http_listen() -> String {
539    "0.0.0.0:8080".into()
540}
541
542impl Default for ListenConfig {
543    fn default() -> Self {
544        Self {
545            http: default_http_listen(),
546            max_connections: None,
547            tls: None,
548            idle_timeout_secs: default_idle_timeout_secs(),
549            header_read_timeout_secs: default_header_read_timeout_secs(),
550        }
551    }
552}
553
554/// Service identity.
555#[derive(Debug, Clone, Deserialize)]
556pub struct ServiceConfig {
557    /// Service name (appears in /health response and metrics namespace).
558    #[serde(default = "default_service_name")]
559    pub name: String,
560}
561
562fn default_service_name() -> String {
563    "structured-proxy".into()
564}
565
566impl Default for ServiceConfig {
567    fn default() -> Self {
568        Self {
569            name: default_service_name(),
570        }
571    }
572}
573
574/// Path alias (rewrite before routing).
575#[derive(Debug, Clone, Deserialize)]
576#[non_exhaustive]
577pub struct AliasConfig {
578    pub from: String,
579    pub to: String,
580}
581
582/// OpenAPI generation config.
583#[derive(Debug, Clone, Deserialize)]
584#[non_exhaustive]
585pub struct OpenApiConfig {
586    #[serde(default = "default_true")]
587    pub enabled: bool,
588    /// Path for OpenAPI JSON spec (default: "/openapi.json").
589    #[serde(default = "default_openapi_path")]
590    pub path: String,
591    /// Path for interactive API docs UI (default: "/docs").
592    #[serde(default = "default_docs_path")]
593    pub docs_path: String,
594    #[serde(default)]
595    pub title: Option<String>,
596    #[serde(default)]
597    pub version: Option<String>,
598}
599
600fn default_openapi_path() -> String {
601    "/openapi.json".into()
602}
603
604fn default_docs_path() -> String {
605    "/docs".into()
606}
607
608fn default_true() -> bool {
609    true
610}
611
612/// Auth configuration.
613#[derive(Debug, Clone, Deserialize)]
614#[non_exhaustive]
615pub struct AuthConfig {
616    /// Auth mode: "none", "jwt", "api_key".
617    #[serde(default = "default_auth_mode")]
618    pub mode: String,
619
620    /// JWT validation config.
621    #[serde(default)]
622    pub jwt: Option<JwtConfig>,
623
624    /// Forward auth endpoint.
625    #[serde(default)]
626    pub forward_auth: Option<ForwardAuthConfig>,
627
628    /// AuthZ integration (optional gRPC call).
629    #[serde(default)]
630    pub authz: Option<AuthzConfig>,
631
632    /// The traffic JWT authentication covers. Default: `transcoded` and
633    /// `endpoints`; the forward-auth endpoint is never behind it.
634    #[serde(default)]
635    pub scope: Option<ScopeConfig>,
636}
637
638/// The traffic a guard covers, and optionally which paths and methods of it.
639///
640/// ```yaml
641/// scope:
642///   traffic: [transcoded, grpc]   # or [all]
643///   paths: ["/v1/**", "/acme.v1.Orders/*"]
644///   methods: ["POST"]
645/// ```
646///
647/// Omitted keys keep the guard's own default traffic and cover every path and
648/// method of it.
649#[derive(Debug, Clone, Default, Deserialize)]
650#[serde(deny_unknown_fields)]
651pub struct ScopeConfig {
652    /// The classes of traffic covered; `all` names every class.
653    #[serde(default)]
654    pub traffic: Option<Vec<Traffic>>,
655    /// Path globs (`*` within a segment, `**` across segments) the guard is
656    /// narrowed to; empty covers every path. A native gRPC call's path is
657    /// `/<package>.<Service>/<Method>`.
658    #[serde(default)]
659    pub paths: Vec<String>,
660    /// Methods the guard is narrowed to; empty covers every method. Each is a
661    /// standard method or one a route answers (a `custom` rule, an extra
662    /// route); a scope covering the fallback takes any method token.
663    #[serde(default)]
664    pub methods: Vec<String>,
665}
666
667impl ScopeConfig {
668    /// A scope covering every path and method of `traffic`.
669    ///
670    /// # Examples
671    ///
672    /// ```
673    /// use structured_proxy::config::{ScopeConfig, Traffic};
674    ///
675    /// let scope = ScopeConfig::traffic([Traffic::Transcoded, Traffic::Grpc]);
676    /// assert!(scope.paths.is_empty());
677    /// ```
678    pub fn traffic(traffic: impl IntoIterator<Item = Traffic>) -> Self {
679        Self {
680            traffic: Some(traffic.into_iter().collect()),
681            paths: Vec::new(),
682            methods: Vec::new(),
683        }
684    }
685}
686
687/// A class of traffic the proxy tells apart before any guard runs.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum Traffic {
691    /// REST calls the proxy transcodes to gRPC.
692    Transcoded,
693    /// The proxy's own endpoints: health, metrics, OpenAPI, OIDC, extra
694    /// routes and forward-auth.
695    Endpoints,
696    /// Native gRPC and gRPC-Web calls passed through to the upstream.
697    Grpc,
698    /// Requests no route answers, handed to the fallback.
699    Fallback,
700    /// Every class above.
701    All,
702}
703
704/// Request concurrency limit: requests past `max_in_flight` are answered
705/// `UNAVAILABLE` (503) at once instead of queueing.
706#[derive(Debug, Clone, Deserialize)]
707#[serde(deny_unknown_fields)]
708pub struct ConcurrencyConfig {
709    /// Most requests in flight at once, counted until each response body
710    /// ends, so a stream holds its slot for its whole life. At least 1.
711    pub max_in_flight: usize,
712    /// The traffic the limit covers, one budget shared by all of it.
713    /// Default: `transcoded` and `grpc`; the proxy's own endpoints stay out,
714    /// so health probes answer while the proxy is saturated.
715    #[serde(default)]
716    pub scope: Option<ScopeConfig>,
717}
718
719fn default_auth_mode() -> String {
720    "none".into()
721}
722
723/// JWT validation config.
724#[derive(Debug, Clone, Deserialize)]
725#[non_exhaustive]
726pub struct JwtConfig {
727    /// JWKS URI for key discovery.
728    #[serde(default)]
729    pub jwks_uri: Option<String>,
730    /// Age in seconds after which the JWKS keys are fetched again, so a key
731    /// the provider removed stops verifying tokens. At least 60, the minimum
732    /// spacing of refreshes. Default: 300.
733    #[serde(default = "default_jwks_max_age_secs")]
734    pub jwks_max_age_secs: u64,
735    /// Expected issuer.
736    #[serde(default)]
737    pub issuer: Option<String>,
738    /// Expected audience.
739    #[serde(default)]
740    pub audience: Option<String>,
741    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
742    #[serde(default)]
743    pub public_key_pem_file: Option<PathBuf>,
744    /// Claims → HTTP headers mapping.
745    #[serde(default)]
746    pub claims_headers: std::collections::HashMap<String, String>,
747    /// Claim holding the user's roles (array of strings). Supports a dotted
748    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
749    #[serde(default = "default_roles_claim")]
750    pub roles_claim: String,
751    /// Cache of verified tokens for the built-in verifier, so a client that
752    /// sends the same token on every request pays for one signature check.
753    #[serde(default)]
754    pub cache: JwtCacheConfig,
755}
756
757/// `auth.jwt.cache`: verified claims are reused until the earlier of the
758/// token's `exp` and `max_ttl_secs` after verification. Applies to the
759/// built-in verifier only; an injected `TokenVerifier` is called every time.
760#[derive(Debug, Clone, Deserialize)]
761#[serde(deny_unknown_fields)]
762#[non_exhaustive]
763pub struct JwtCacheConfig {
764    /// Cache verified tokens. Default: true.
765    #[serde(default = "default_true")]
766    pub enabled: bool,
767    /// Most tokens kept at once; once full with live entries, a new token is
768    /// verified but not stored. Default: 10000.
769    #[serde(default = "default_jwt_cache_max_entries")]
770    pub max_entries: usize,
771    /// Longest time a verification is reused, in seconds. With
772    /// `jwks_max_age_secs` it bounds how long a token keeps passing after its
773    /// signing key leaves the JWKS. Default: 60.
774    #[serde(default = "default_jwt_cache_max_ttl_secs")]
775    pub max_ttl_secs: u64,
776    /// Longest token cached, in bytes; a longer one is verified on every
777    /// request. With `max_entries` it bounds the memory the cache holds, since
778    /// the claims kept are decoded from the token. Default: 4096.
779    #[serde(default = "default_jwt_cache_max_token_bytes")]
780    pub max_token_bytes: usize,
781}
782
783/// Default for [`JwtConfig::jwks_max_age_secs`].
784pub(crate) fn default_jwks_max_age_secs() -> u64 {
785    300
786}
787
788fn default_jwt_cache_max_entries() -> usize {
789    10_000
790}
791
792fn default_jwt_cache_max_ttl_secs() -> u64 {
793    60
794}
795
796fn default_jwt_cache_max_token_bytes() -> usize {
797    4096
798}
799
800impl Default for JwtCacheConfig {
801    fn default() -> Self {
802        Self {
803            enabled: true,
804            max_entries: default_jwt_cache_max_entries(),
805            max_ttl_secs: default_jwt_cache_max_ttl_secs(),
806            max_token_bytes: default_jwt_cache_max_token_bytes(),
807        }
808    }
809}
810
811/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when
812/// an injected verifier makes the whole `auth.jwt` block optional.
813pub(crate) fn default_roles_claim() -> String {
814    "roles".into()
815}
816
817/// Forward auth config.
818#[derive(Debug, Clone, Deserialize)]
819#[non_exhaustive]
820pub struct ForwardAuthConfig {
821    #[serde(default)]
822    pub enabled: bool,
823    #[serde(default = "default_forward_auth_path")]
824    pub path: String,
825    /// Route policies.
826    #[serde(default)]
827    pub policies: Vec<RoutePolicyConfig>,
828    /// Login URL for 401 redirects.
829    #[serde(default)]
830    pub login_url: Option<String>,
831    /// Applications YAML file path.
832    #[serde(default)]
833    pub applications_path: Option<PathBuf>,
834}
835
836fn default_forward_auth_path() -> String {
837    "/auth/verify".into()
838}
839
840/// Route policy entry.
841#[derive(Debug, Clone, Deserialize)]
842#[non_exhaustive]
843pub struct RoutePolicyConfig {
844    pub path: String,
845    #[serde(default = "default_methods_all")]
846    pub methods: Vec<String>,
847    #[serde(default)]
848    pub require_auth: bool,
849    #[serde(default)]
850    pub required_roles: Vec<String>,
851}
852
853fn default_methods_all() -> Vec<String> {
854    vec!["*".into()]
855}
856
857/// External authorization via the Envoy ext_authz gRPC contract
858/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
859/// ext_authz server.
860#[derive(Debug, Clone, Deserialize)]
861#[non_exhaustive]
862pub struct AuthzConfig {
863    /// Enable external authorization for proxied API requests.
864    #[serde(default)]
865    pub enabled: bool,
866    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
867    /// when enabled; defaults to empty so a disabled block can omit it.
868    #[serde(default)]
869    pub endpoint: String,
870    /// Per-request authorization call timeout, in milliseconds.
871    #[serde(default = "default_authz_timeout_ms")]
872    pub timeout_ms: u64,
873    /// When the authz call itself fails (unreachable / timeout), allow the
874    /// request through instead of denying. Defaults to false (fail closed).
875    #[serde(default)]
876    pub failure_mode_allow: bool,
877    /// The traffic the check covers. Default: `transcoded`.
878    #[serde(default)]
879    pub scope: Option<ScopeConfig>,
880}
881
882fn default_authz_timeout_ms() -> u64 {
883    200
884}
885
886/// Shield (rate limiting) configuration.
887///
888/// The proxy runs embedded on each service instance, so every limit decision is
889/// made locally with a GCRA shaper (zero blocking latency). A shared store, when
890/// configured via [`sync`](ShieldConfig::sync), is reconciled asynchronously off
891/// the request path to approximate a fleet-wide limit; the request path never
892/// blocks on it.
893#[derive(Debug, Clone, Deserialize)]
894#[serde(deny_unknown_fields)]
895#[non_exhaustive]
896pub struct ShieldConfig {
897    #[serde(default)]
898    pub enabled: bool,
899    /// Named limit tiers referenced by rules and by tier-name resolution (JWT
900    /// claim / limit service). Map of profile name → `{ rate, burst }`.
901    #[serde(default)]
902    pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
903    /// Rate-limit rules, evaluated in order; the first whose pattern matches the
904    /// request path applies.
905    #[serde(default)]
906    pub rules: Vec<RateRuleConfig>,
907    /// Profile name applied when a matched rule resolves no other limit (JWT and
908    /// service resolution absent or empty, and the rule sets no explicit
909    /// profile). Must name an entry in `profiles`.
910    #[serde(default)]
911    pub default_profile: Option<String>,
912    /// Resolve a key's limit from claims in the validated JWT. Presence enables
913    /// JWT-based resolution (tier name or explicit numbers).
914    #[serde(default)]
915    pub jwt_limits: Option<JwtLimitConfig>,
916    /// Resolve a key's limit from an external service. The lookup is cached and
917    /// refreshed in the background; the request path never blocks on it.
918    #[serde(default)]
919    pub limit_service: Option<LimitServiceConfig>,
920    /// Asynchronous cross-instance reconciliation via a shared store. When unset,
921    /// each instance limits locally (fleet limit ≈ N × per-instance).
922    #[serde(default)]
923    pub sync: Option<SyncConfig>,
924    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
925    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
926    /// direct peer falls in one of these ranges; otherwise the peer socket
927    /// address is used as the client identity. Empty (the default) means do not
928    /// trust forwarding headers; set this behind a load balancer.
929    #[serde(default)]
930    pub trusted_proxies: Vec<String>,
931    /// The traffic the rules apply to. Default: `transcoded` and `endpoints`.
932    #[serde(default)]
933    pub scope: Option<ScopeConfig>,
934}
935
936/// A named limit tier: a sustained rate plus an instantaneous burst capacity.
937#[derive(Debug, Clone, Deserialize)]
938#[serde(deny_unknown_fields)]
939#[non_exhaustive]
940pub struct LimitProfileConfig {
941    /// Sustained rate as `"<count>/<unit>"` (e.g. `"100/min"`, units
942    /// `s`/`min`/`hour`) or a bare count (interpreted per minute).
943    pub rate: String,
944    /// Maximum requests admitted back-to-back before throttling to the rate.
945    /// Defaults to the per-window rate count (one full window of burst).
946    #[serde(default)]
947    pub burst: Option<u64>,
948}
949
950/// One rate-limit rule: a path pattern, how to key it, and an optional static
951/// profile. The rule's *phase* (before or after auth) is derived from its key
952/// alone: a `jwt_claim` key needs validated claims so it runs after auth; `ip`
953/// and `header` keys run before auth so anonymous floods are shed before any
954/// signature verification. (`jwt_limits` therefore only takes effect on
955/// `jwt_claim` rules, the only ones running with claims available.)
956#[derive(Debug, Clone, Deserialize)]
957#[serde(deny_unknown_fields)]
958#[non_exhaustive]
959pub struct RateRuleConfig {
960    /// Glob path pattern (`*` within a segment, `**` across segments).
961    pub pattern: String,
962    /// How to derive the limit key (who is limited). Defaults to client IP.
963    #[serde(default)]
964    pub key: KeySourceConfig,
965    /// Static profile name for this rule, used when JWT/service resolution does
966    /// not apply or yields nothing. Must name an entry in `profiles`.
967    #[serde(default)]
968    pub profile: Option<String>,
969}
970
971/// How a rule derives its limit key. All sources fall back to the client IP when
972/// their value is absent, so a limit can't be bypassed by omitting a header or
973/// authenticating anonymously. Written as a tagged map, e.g.
974/// `key: { type: jwt_claim, claim: sub }`; omitting `key` defaults to `ip`.
975#[derive(Debug, Clone, Default, PartialEq, Eq)]
976#[non_exhaustive]
977pub enum KeySourceConfig {
978    /// Client IP (trusted-proxy `X-Forwarded-For` aware). `{ type: ip }`.
979    #[default]
980    Ip,
981    /// Value of a named request header (e.g. an API key).
982    /// `{ type: header, name: x-api-key }`.
983    Header {
984        /// Header whose value identifies the client.
985        name: String,
986    },
987    /// Value of a claim from the validated JWT (provider-agnostic).
988    /// `{ type: jwt_claim, claim: sub }`.
989    JwtClaim {
990        /// Claim whose value identifies the principal.
991        claim: String,
992    },
993}
994
995/// Flat wire form of a rule key. `deny_unknown_fields` rejects any field outside
996/// this set, and the manual [`KeySourceConfig`] deserializer additionally rejects
997/// a field that belongs to a *different* variant (e.g. `name` on an `ip` key), so
998/// a copy-edit leftover can't silently downgrade the intended key source. serde
999/// does not honour `deny_unknown_fields` on internally-tagged enums directly,
1000/// hence this intermediate.
1001#[derive(Deserialize)]
1002#[serde(deny_unknown_fields, rename_all = "snake_case")]
1003struct KeySourceRaw {
1004    #[serde(rename = "type")]
1005    kind: String,
1006    #[serde(default)]
1007    name: Option<String>,
1008    #[serde(default)]
1009    claim: Option<String>,
1010}
1011
1012impl<'de> Deserialize<'de> for KeySourceConfig {
1013    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1014    where
1015        D: serde::Deserializer<'de>,
1016    {
1017        use serde::de::Error;
1018        let raw = KeySourceRaw::deserialize(deserializer)?;
1019        match raw.kind.as_str() {
1020            "ip" => {
1021                if raw.name.is_some() || raw.claim.is_some() {
1022                    return Err(D::Error::custom("key type 'ip' takes no other fields"));
1023                }
1024                Ok(Self::Ip)
1025            }
1026            "header" => {
1027                if raw.claim.is_some() {
1028                    return Err(D::Error::custom(
1029                        "key type 'header' takes 'name', not 'claim'",
1030                    ));
1031                }
1032                let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
1033                Ok(Self::Header { name })
1034            }
1035            "jwt_claim" => {
1036                if raw.name.is_some() {
1037                    return Err(D::Error::custom(
1038                        "key type 'jwt_claim' takes 'claim', not 'name'",
1039                    ));
1040                }
1041                let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
1042                Ok(Self::JwtClaim { claim })
1043            }
1044            other => Err(D::Error::unknown_variant(
1045                other,
1046                &["ip", "header", "jwt_claim"],
1047            )),
1048        }
1049    }
1050}
1051
1052/// Claims that carry a key's limit inside the JWT itself. A tier-name claim maps
1053/// to a `profiles` entry (numbers stay tunable in config); direct numeric claims
1054/// set the limit explicitly.
1055#[derive(Debug, Clone, Deserialize)]
1056#[serde(deny_unknown_fields)]
1057#[non_exhaustive]
1058pub struct JwtLimitConfig {
1059    /// Claim naming a profile tier (e.g. `"premium"`). Default: `ratelimit_tier`.
1060    #[serde(default = "default_tier_claim")]
1061    pub tier_claim: String,
1062    /// Claim carrying an explicit sustained rate, requests per minute. Default:
1063    /// `ratelimit_rpm`.
1064    #[serde(default = "default_rpm_claim")]
1065    pub rpm_claim: String,
1066    /// Claim carrying an explicit burst capacity. Default: `ratelimit_burst`.
1067    #[serde(default = "default_burst_claim")]
1068    pub burst_claim: String,
1069}
1070
1071fn default_tier_claim() -> String {
1072    "ratelimit_tier".to_string()
1073}
1074fn default_rpm_claim() -> String {
1075    "ratelimit_rpm".to_string()
1076}
1077fn default_burst_claim() -> String {
1078    "ratelimit_burst".to_string()
1079}
1080
1081/// External limit-resolution service. The response names a tier or gives explicit
1082/// numbers; results are cached and refreshed asynchronously, never on the request
1083/// path.
1084#[derive(Debug, Clone, Deserialize)]
1085#[serde(deny_unknown_fields)]
1086#[non_exhaustive]
1087pub struct LimitServiceConfig {
1088    /// HTTP endpoint queried with the limit key; returns `{ tier }` or
1089    /// `{ rate_per_min, burst }`.
1090    pub endpoint: String,
1091    /// How long a resolved limit is cached before a background refresh, in
1092    /// seconds (default: 300).
1093    #[serde(default = "default_limit_ttl_secs")]
1094    pub ttl_secs: u64,
1095    /// Timeout for the background fetch, in milliseconds (default: 500).
1096    #[serde(default = "default_limit_timeout_ms")]
1097    pub timeout_ms: u64,
1098}
1099
1100fn default_limit_ttl_secs() -> u64 {
1101    300
1102}
1103fn default_limit_timeout_ms() -> u64 {
1104    500
1105}
1106
1107/// Asynchronous cross-instance reconciliation via a shared store.
1108#[derive(Debug, Clone, Deserialize)]
1109#[serde(deny_unknown_fields)]
1110#[non_exhaustive]
1111pub struct SyncConfig {
1112    /// Shared-store URL (e.g. `"redis://127.0.0.1/"`). Requires the `redis` build
1113    /// feature; without it the proxy logs a warning and stays local-only.
1114    pub redis_url: String,
1115    /// Background push/pull interval in milliseconds (default: 500). The
1116    /// worst-case fleet overshoot is bounded by `(N-1) × rate × interval`.
1117    #[serde(default = "default_sync_interval_ms")]
1118    pub interval_ms: u64,
1119}
1120
1121fn default_sync_interval_ms() -> u64 {
1122    500
1123}
1124
1125/// OIDC discovery config.
1126#[derive(Debug, Clone, Deserialize)]
1127#[non_exhaustive]
1128pub struct OidcDiscoveryConfig {
1129    #[serde(default)]
1130    pub enabled: bool,
1131    pub issuer: String,
1132    #[serde(default)]
1133    pub authorization_endpoint: Option<String>,
1134    #[serde(default)]
1135    pub token_endpoint: Option<String>,
1136    #[serde(default)]
1137    pub userinfo_endpoint: Option<String>,
1138    #[serde(default)]
1139    pub jwks_uri: Option<String>,
1140    #[serde(default)]
1141    pub signing_key: Option<SigningKeyConfig>,
1142}
1143
1144/// Signing key config for JWKS endpoint.
1145#[derive(Debug, Clone, Deserialize)]
1146#[non_exhaustive]
1147pub struct SigningKeyConfig {
1148    #[serde(default = "default_algorithm")]
1149    pub algorithm: String,
1150    pub public_key_pem_file: PathBuf,
1151}
1152
1153fn default_algorithm() -> String {
1154    "EdDSA".into()
1155}
1156
1157/// Health-probe endpoint configuration.
1158///
1159/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
1160/// `/internal/` prefix) or disable them when a fronting platform supplies its
1161/// own. Defaults match the conventional `/health*` layout.
1162#[derive(Debug, Clone, Deserialize)]
1163#[non_exhaustive]
1164pub struct HealthConfig {
1165    /// Mount the health endpoints. Default: true.
1166    #[serde(default = "default_true")]
1167    pub enabled: bool,
1168    /// Aggregate health endpoint. Default: `/health`.
1169    #[serde(default = "default_health_path")]
1170    pub path: String,
1171    /// Liveness probe. Default: `/health/live`.
1172    #[serde(default = "default_health_live_path")]
1173    pub live_path: String,
1174    /// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
1175    #[serde(default = "default_health_ready_path")]
1176    pub ready_path: String,
1177    /// Startup probe. Default: `/health/startup`.
1178    #[serde(default = "default_health_startup_path")]
1179    pub startup_path: String,
1180}
1181
1182fn default_health_path() -> String {
1183    "/health".into()
1184}
1185fn default_health_live_path() -> String {
1186    "/health/live".into()
1187}
1188fn default_health_ready_path() -> String {
1189    "/health/ready".into()
1190}
1191fn default_health_startup_path() -> String {
1192    "/health/startup".into()
1193}
1194
1195impl Default for HealthConfig {
1196    fn default() -> Self {
1197        Self {
1198            enabled: true,
1199            path: default_health_path(),
1200            live_path: default_health_live_path(),
1201            ready_path: default_health_ready_path(),
1202            startup_path: default_health_startup_path(),
1203        }
1204    }
1205}
1206
1207/// Prometheus metrics endpoint configuration.
1208#[derive(Debug, Clone, Deserialize)]
1209#[non_exhaustive]
1210pub struct MetricsConfig {
1211    /// Mount the metrics endpoint. Default: true.
1212    #[serde(default = "default_true")]
1213    pub enabled: bool,
1214    /// Scrape path. Default: `/metrics`.
1215    #[serde(default = "default_metrics_path")]
1216    pub path: String,
1217}
1218
1219fn default_metrics_path() -> String {
1220    "/metrics".into()
1221}
1222
1223impl Default for MetricsConfig {
1224    fn default() -> Self {
1225        Self {
1226            enabled: true,
1227            path: default_metrics_path(),
1228        }
1229    }
1230}
1231
1232/// Maintenance mode config.
1233#[derive(Debug, Clone, Deserialize)]
1234#[non_exhaustive]
1235pub struct MaintenanceConfig {
1236    #[serde(default)]
1237    pub enabled: bool,
1238    /// Paths exempt from maintenance mode (glob patterns).
1239    #[serde(default = "default_exempt_paths")]
1240    pub exempt_paths: Vec<String>,
1241    #[serde(default = "default_maintenance_message")]
1242    pub message: String,
1243    /// The traffic maintenance mode turns away. Default: `transcoded` and
1244    /// `endpoints`.
1245    #[serde(default)]
1246    pub scope: Option<ScopeConfig>,
1247}
1248
1249fn default_exempt_paths() -> Vec<String> {
1250    vec![
1251        "/health/**".into(),
1252        "/.well-known/**".into(),
1253        "/metrics".into(),
1254        "/auth/verify".into(),
1255    ]
1256}
1257
1258fn default_maintenance_message() -> String {
1259    "Service is under maintenance. Please try again later.".into()
1260}
1261
1262impl Default for MaintenanceConfig {
1263    fn default() -> Self {
1264        Self {
1265            enabled: false,
1266            exempt_paths: default_exempt_paths(),
1267            message: default_maintenance_message(),
1268            scope: None,
1269        }
1270    }
1271}
1272
1273/// CORS configuration.
1274#[derive(Debug, Clone, Deserialize)]
1275#[non_exhaustive]
1276pub struct CorsConfig {
1277    /// Allowed origins. Empty = permissive (dev mode).
1278    #[serde(default)]
1279    pub origins: Vec<String>,
1280    /// Response headers a browser script may read on top of the ones the
1281    /// proxy always exposes (`grpc-status`, `grpc-message`,
1282    /// `grpc-status-details-bin` and the rate-limit headers): typically
1283    /// upstream metadata forwarded as a header, such as `x-request-id`.
1284    #[serde(default)]
1285    pub expose_headers: Vec<String>,
1286    /// How long a browser may cache a preflight answer, in seconds. Unset,
1287    /// the browser's own default applies.
1288    #[serde(default)]
1289    pub max_age_secs: Option<u64>,
1290    /// Apply this policy to gRPC-Web calls that pass through to the upstream
1291    /// and to their preflights, so a browser gets one policy for both (on by
1292    /// default). Off only for an upstream that sets CORS on gRPC-Web itself:
1293    /// its preflights then reach the upstream as well.
1294    #[serde(default = "default_true")]
1295    pub grpc_web: bool,
1296}
1297
1298impl Default for CorsConfig {
1299    fn default() -> Self {
1300        Self {
1301            origins: Vec::new(),
1302            expose_headers: Vec::new(),
1303            max_age_secs: None,
1304            grpc_web: true,
1305        }
1306    }
1307}
1308
1309/// Logging configuration.
1310#[derive(Debug, Clone, Deserialize)]
1311#[non_exhaustive]
1312pub struct LoggingConfig {
1313    #[serde(default = "default_log_level")]
1314    pub level: String,
1315    #[serde(default = "default_log_format")]
1316    pub format: String,
1317}
1318
1319fn default_log_level() -> String {
1320    "info".into()
1321}
1322fn default_log_format() -> String {
1323    "json".into()
1324}
1325
1326impl Default for LoggingConfig {
1327    fn default() -> Self {
1328        Self {
1329            level: default_log_level(),
1330            format: default_log_format(),
1331        }
1332    }
1333}
1334
1335/// Metrics endpoint classification.
1336#[derive(Debug, Clone, Deserialize)]
1337#[non_exhaustive]
1338pub struct MetricsClassConfig {
1339    /// Glob pattern for path matching.
1340    pub pattern: String,
1341    /// Label value for this class.
1342    pub class: String,
1343}
1344
1345impl ProxyConfig {
1346    /// Load configuration from a YAML file.
1347    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1348        Self::from_yaml_str(&std::fs::read_to_string(path)?)
1349    }
1350
1351    /// Parse configuration from a YAML string.
1352    ///
1353    /// Useful for embedding the proxy: load a baked-in config (e.g. via
1354    /// `include_str!`) without touching the filesystem.
1355    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1356        let config: Self = serde_yaml::from_str(yaml)?;
1357        config.validate()?;
1358        Ok(config)
1359    }
1360
1361    /// Validate cross-field constraints that the type system can't express.
1362    ///
1363    /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
1364    /// directly when building a [`ProxyConfig`] programmatically so the same
1365    /// invariants are enforced on the embedded path.
1366    pub fn validate(&self) -> anyhow::Result<()> {
1367        if self.streaming.sse_keep_alive_secs == 0 {
1368            anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1369        }
1370        if self.grpc_web.translate && !self.cors.grpc_web {
1371            anyhow::bail!(
1372                "grpc_web.translate needs cors.grpc_web: an upstream that speaks only \
1373                 gRPC cannot answer a browser's preflight for a gRPC-Web call"
1374            );
1375        }
1376        self.validate_edge_paths()?;
1377        Ok(())
1378    }
1379
1380    /// Reject malformed or duplicate built-in edge paths up front, so the router
1381    /// does not panic at construction (axum rejects a route that does not start
1382    /// with `/`, and panics on a path registered twice, e.g. setting
1383    /// `health.path` to the default `live_path`).
1384    fn validate_edge_paths(&self) -> anyhow::Result<()> {
1385        let mut seen = std::collections::HashSet::new();
1386        let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1387            if !path.starts_with('/') {
1388                anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1389            }
1390            if !seen.insert(path.to_string()) {
1391                anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1392            }
1393            Ok(())
1394        };
1395        if self.health.enabled {
1396            check("health.path", &self.health.path)?;
1397            check("health.live_path", &self.health.live_path)?;
1398            check("health.ready_path", &self.health.ready_path)?;
1399            check("health.startup_path", &self.health.startup_path)?;
1400        }
1401        if self.metrics.enabled {
1402            check("metrics.path", &self.metrics.path)?;
1403        }
1404        if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1405            check("openapi.path", &openapi.path)?;
1406            check("openapi.docs_path", &openapi.docs_path)?;
1407        }
1408        Ok(())
1409    }
1410
1411    /// Parse rate string like "20/min" → requests per window.
1412    pub fn parse_rate(rate: &str) -> Option<u32> {
1413        let parts: Vec<&str> = rate.split('/').collect();
1414        if parts.len() != 2 {
1415            return None;
1416        }
1417        parts[0].trim().parse().ok()
1418    }
1419}
1420
1421#[cfg(test)]
1422mod tests;