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    /// Seconds a graceful shutdown waits for open connections to finish what
483    /// they serve; the ones still open after it are closed. 0 waits for all.
484    /// Default: 25, below the 30 s grace period Kubernetes gives by default.
485    #[serde(default = "default_drain_timeout_secs")]
486    pub drain_timeout_secs: u64,
487}
488
489fn default_drain_timeout_secs() -> u64 {
490    25
491}
492
493fn default_idle_timeout_secs() -> u64 {
494    60
495}
496
497fn default_header_read_timeout_secs() -> u64 {
498    30
499}
500
501fn default_tls_handshake_timeout_secs() -> u64 {
502    10
503}
504
505/// TLS for the listener.
506///
507/// ```yaml
508/// tls:
509///   cert_file: /etc/proxy/tls.crt      # PEM chain, leaf first
510///   key_file: /etc/proxy/tls.key       # PEM private key
511///   client_ca_file: /etc/proxy/ca.crt  # optional: verify client certificates
512///   client_auth: required              # or `optional`
513/// ```
514#[derive(Debug, Clone, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct ListenTlsConfig {
517    /// The server certificate chain, PEM, leaf first.
518    pub cert_file: PathBuf,
519    /// The server private key, PEM (PKCS#8, PKCS#1 or SEC1).
520    pub key_file: PathBuf,
521    /// CA certificates, PEM, that client certificates are verified against.
522    /// Unset: clients present none.
523    #[serde(default)]
524    pub client_ca_file: Option<PathBuf>,
525    /// Whether a client must present a certificate; `required` when
526    /// `client_ca_file` is set and this is not. Setting it without
527    /// `client_ca_file` is an error: nothing could verify the certificate.
528    #[serde(default)]
529    pub client_auth: Option<ClientAuth>,
530    /// Seconds a client has to finish the TLS handshake. At least 1.
531    /// Default: 10.
532    #[serde(default = "default_tls_handshake_timeout_secs")]
533    pub handshake_timeout_secs: u64,
534}
535
536/// Whether a TLS client must present a certificate.
537#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
538#[serde(rename_all = "snake_case")]
539pub enum ClientAuth {
540    /// A client without a valid certificate is refused in the handshake.
541    #[default]
542    Required,
543    /// A certificate is verified when presented; a client may present none.
544    Optional,
545}
546
547fn default_http_listen() -> String {
548    "0.0.0.0:8080".into()
549}
550
551impl Default for ListenConfig {
552    fn default() -> Self {
553        Self {
554            http: default_http_listen(),
555            max_connections: None,
556            tls: None,
557            idle_timeout_secs: default_idle_timeout_secs(),
558            header_read_timeout_secs: default_header_read_timeout_secs(),
559            drain_timeout_secs: default_drain_timeout_secs(),
560        }
561    }
562}
563
564/// Service identity.
565#[derive(Debug, Clone, Deserialize)]
566pub struct ServiceConfig {
567    /// Service name (appears in /health response and metrics namespace).
568    #[serde(default = "default_service_name")]
569    pub name: String,
570}
571
572fn default_service_name() -> String {
573    "structured-proxy".into()
574}
575
576impl Default for ServiceConfig {
577    fn default() -> Self {
578        Self {
579            name: default_service_name(),
580        }
581    }
582}
583
584/// Path alias (rewrite before routing).
585#[derive(Debug, Clone, Deserialize)]
586#[non_exhaustive]
587pub struct AliasConfig {
588    pub from: String,
589    pub to: String,
590}
591
592/// OpenAPI generation config.
593#[derive(Debug, Clone, Deserialize)]
594#[non_exhaustive]
595pub struct OpenApiConfig {
596    #[serde(default = "default_true")]
597    pub enabled: bool,
598    /// Path for OpenAPI JSON spec (default: "/openapi.json").
599    #[serde(default = "default_openapi_path")]
600    pub path: String,
601    /// Path for interactive API docs UI (default: "/docs").
602    #[serde(default = "default_docs_path")]
603    pub docs_path: String,
604    #[serde(default)]
605    pub title: Option<String>,
606    #[serde(default)]
607    pub version: Option<String>,
608}
609
610fn default_openapi_path() -> String {
611    "/openapi.json".into()
612}
613
614fn default_docs_path() -> String {
615    "/docs".into()
616}
617
618fn default_true() -> bool {
619    true
620}
621
622/// Auth configuration.
623#[derive(Debug, Clone, Deserialize)]
624#[non_exhaustive]
625pub struct AuthConfig {
626    /// Auth mode: "none", "jwt", "api_key".
627    #[serde(default = "default_auth_mode")]
628    pub mode: String,
629
630    /// JWT validation config.
631    #[serde(default)]
632    pub jwt: Option<JwtConfig>,
633
634    /// Forward auth endpoint.
635    #[serde(default)]
636    pub forward_auth: Option<ForwardAuthConfig>,
637
638    /// AuthZ integration (optional gRPC call).
639    #[serde(default)]
640    pub authz: Option<AuthzConfig>,
641
642    /// The traffic JWT authentication covers. Default: `transcoded` and
643    /// `endpoints`; the forward-auth endpoint is never behind it.
644    #[serde(default)]
645    pub scope: Option<ScopeConfig>,
646}
647
648/// The traffic a guard covers, and optionally which paths and methods of it.
649///
650/// ```yaml
651/// scope:
652///   traffic: [transcoded, grpc]   # or [all]
653///   paths: ["/v1/**", "/acme.v1.Orders/*"]
654///   methods: ["POST"]
655/// ```
656///
657/// Omitted keys keep the guard's own default traffic and cover every path and
658/// method of it.
659#[derive(Debug, Clone, Default, Deserialize)]
660#[serde(deny_unknown_fields)]
661pub struct ScopeConfig {
662    /// The classes of traffic covered; `all` names every class.
663    #[serde(default)]
664    pub traffic: Option<Vec<Traffic>>,
665    /// Path globs (`*` within a segment, `**` across segments) the guard is
666    /// narrowed to; empty covers every path. A native gRPC call's path is
667    /// `/<package>.<Service>/<Method>`.
668    #[serde(default)]
669    pub paths: Vec<String>,
670    /// Methods the guard is narrowed to; empty covers every method. Each is a
671    /// standard method or one a route answers (a `custom` rule, an extra
672    /// route); a scope covering the fallback takes any method token.
673    #[serde(default)]
674    pub methods: Vec<String>,
675}
676
677impl ScopeConfig {
678    /// A scope covering every path and method of `traffic`.
679    ///
680    /// # Examples
681    ///
682    /// ```
683    /// use structured_proxy::config::{ScopeConfig, Traffic};
684    ///
685    /// let scope = ScopeConfig::traffic([Traffic::Transcoded, Traffic::Grpc]);
686    /// assert!(scope.paths.is_empty());
687    /// ```
688    pub fn traffic(traffic: impl IntoIterator<Item = Traffic>) -> Self {
689        Self {
690            traffic: Some(traffic.into_iter().collect()),
691            paths: Vec::new(),
692            methods: Vec::new(),
693        }
694    }
695}
696
697/// A class of traffic the proxy tells apart before any guard runs.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum Traffic {
701    /// REST calls the proxy transcodes to gRPC.
702    Transcoded,
703    /// The proxy's own endpoints: health, metrics, OpenAPI, OIDC, extra
704    /// routes and forward-auth.
705    Endpoints,
706    /// Native gRPC and gRPC-Web calls passed through to the upstream.
707    Grpc,
708    /// Requests no route answers, handed to the fallback.
709    Fallback,
710    /// Every class above.
711    All,
712}
713
714/// Request concurrency limit: requests past `max_in_flight` are answered
715/// `UNAVAILABLE` (503) at once instead of queueing.
716#[derive(Debug, Clone, Deserialize)]
717#[serde(deny_unknown_fields)]
718pub struct ConcurrencyConfig {
719    /// Most requests in flight at once, counted until each response body
720    /// ends, so a stream holds its slot for its whole life. At least 1.
721    pub max_in_flight: usize,
722    /// The traffic the limit covers, one budget shared by all of it.
723    /// Default: `transcoded` and `grpc`; the proxy's own endpoints stay out,
724    /// so health probes answer while the proxy is saturated.
725    #[serde(default)]
726    pub scope: Option<ScopeConfig>,
727}
728
729fn default_auth_mode() -> String {
730    "none".into()
731}
732
733/// JWT validation config.
734#[derive(Debug, Clone, Deserialize)]
735#[non_exhaustive]
736pub struct JwtConfig {
737    /// JWKS URI for key discovery.
738    #[serde(default)]
739    pub jwks_uri: Option<String>,
740    /// Age in seconds after which the JWKS keys are fetched again, so a key
741    /// the provider removed stops verifying tokens. At least 60, the minimum
742    /// spacing of refreshes. Default: 300.
743    #[serde(default = "default_jwks_max_age_secs")]
744    pub jwks_max_age_secs: u64,
745    /// Expected issuer.
746    #[serde(default)]
747    pub issuer: Option<String>,
748    /// Expected audience.
749    #[serde(default)]
750    pub audience: Option<String>,
751    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
752    #[serde(default)]
753    pub public_key_pem_file: Option<PathBuf>,
754    /// Claims → HTTP headers mapping.
755    #[serde(default)]
756    pub claims_headers: std::collections::HashMap<String, String>,
757    /// Claim holding the user's roles (array of strings). Supports a dotted
758    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
759    #[serde(default = "default_roles_claim")]
760    pub roles_claim: String,
761    /// Cache of verified tokens for the built-in verifier, so a client that
762    /// sends the same token on every request pays for one signature check.
763    #[serde(default)]
764    pub cache: JwtCacheConfig,
765}
766
767/// `auth.jwt.cache`: verified claims are reused until the earlier of the
768/// token's `exp` and `max_ttl_secs` after verification. Applies to the
769/// built-in verifier only; an injected `TokenVerifier` is called every time.
770#[derive(Debug, Clone, Deserialize)]
771#[serde(deny_unknown_fields)]
772#[non_exhaustive]
773pub struct JwtCacheConfig {
774    /// Cache verified tokens. Default: true.
775    #[serde(default = "default_true")]
776    pub enabled: bool,
777    /// Most tokens kept at once; once full with live entries, a new token is
778    /// verified but not stored. Default: 10000.
779    #[serde(default = "default_jwt_cache_max_entries")]
780    pub max_entries: usize,
781    /// Longest time a verification is reused, in seconds. With
782    /// `jwks_max_age_secs` it bounds how long a token keeps passing after its
783    /// signing key leaves the JWKS. Default: 60.
784    #[serde(default = "default_jwt_cache_max_ttl_secs")]
785    pub max_ttl_secs: u64,
786    /// Longest token cached, in bytes; a longer one is verified on every
787    /// request. With `max_entries` it bounds the memory the cache holds, since
788    /// the claims kept are decoded from the token. Default: 4096.
789    #[serde(default = "default_jwt_cache_max_token_bytes")]
790    pub max_token_bytes: usize,
791}
792
793/// Default for [`JwtConfig::jwks_max_age_secs`].
794pub(crate) fn default_jwks_max_age_secs() -> u64 {
795    300
796}
797
798fn default_jwt_cache_max_entries() -> usize {
799    10_000
800}
801
802fn default_jwt_cache_max_ttl_secs() -> u64 {
803    60
804}
805
806fn default_jwt_cache_max_token_bytes() -> usize {
807    4096
808}
809
810impl Default for JwtCacheConfig {
811    fn default() -> Self {
812        Self {
813            enabled: true,
814            max_entries: default_jwt_cache_max_entries(),
815            max_ttl_secs: default_jwt_cache_max_ttl_secs(),
816            max_token_bytes: default_jwt_cache_max_token_bytes(),
817        }
818    }
819}
820
821/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when
822/// an injected verifier makes the whole `auth.jwt` block optional.
823pub(crate) fn default_roles_claim() -> String {
824    "roles".into()
825}
826
827/// Forward auth config.
828#[derive(Debug, Clone, Deserialize)]
829#[non_exhaustive]
830pub struct ForwardAuthConfig {
831    #[serde(default)]
832    pub enabled: bool,
833    #[serde(default = "default_forward_auth_path")]
834    pub path: String,
835    /// Route policies.
836    #[serde(default)]
837    pub policies: Vec<RoutePolicyConfig>,
838    /// Login URL for 401 redirects.
839    #[serde(default)]
840    pub login_url: Option<String>,
841    /// Applications YAML file path.
842    #[serde(default)]
843    pub applications_path: Option<PathBuf>,
844}
845
846fn default_forward_auth_path() -> String {
847    "/auth/verify".into()
848}
849
850/// Route policy entry.
851#[derive(Debug, Clone, Deserialize)]
852#[non_exhaustive]
853pub struct RoutePolicyConfig {
854    pub path: String,
855    #[serde(default = "default_methods_all")]
856    pub methods: Vec<String>,
857    #[serde(default)]
858    pub require_auth: bool,
859    #[serde(default)]
860    pub required_roles: Vec<String>,
861}
862
863fn default_methods_all() -> Vec<String> {
864    vec!["*".into()]
865}
866
867/// External authorization via the Envoy ext_authz gRPC contract
868/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
869/// ext_authz server.
870#[derive(Debug, Clone, Deserialize)]
871#[non_exhaustive]
872pub struct AuthzConfig {
873    /// Enable external authorization for proxied API requests.
874    #[serde(default)]
875    pub enabled: bool,
876    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
877    /// when enabled; defaults to empty so a disabled block can omit it.
878    #[serde(default)]
879    pub endpoint: String,
880    /// Per-request authorization call timeout, in milliseconds.
881    #[serde(default = "default_authz_timeout_ms")]
882    pub timeout_ms: u64,
883    /// When the authz call itself fails (unreachable / timeout), allow the
884    /// request through instead of denying. Defaults to false (fail closed).
885    #[serde(default)]
886    pub failure_mode_allow: bool,
887    /// The traffic the check covers. Default: `transcoded`.
888    #[serde(default)]
889    pub scope: Option<ScopeConfig>,
890}
891
892fn default_authz_timeout_ms() -> u64 {
893    200
894}
895
896/// Shield (rate limiting) configuration.
897///
898/// The proxy runs embedded on each service instance, so every limit decision is
899/// made locally with a GCRA shaper (zero blocking latency). A shared store, when
900/// configured via [`sync`](ShieldConfig::sync), is reconciled asynchronously off
901/// the request path to approximate a fleet-wide limit; the request path never
902/// blocks on it.
903#[derive(Debug, Clone, Deserialize)]
904#[serde(deny_unknown_fields)]
905#[non_exhaustive]
906pub struct ShieldConfig {
907    #[serde(default)]
908    pub enabled: bool,
909    /// Named limit tiers referenced by rules and by tier-name resolution (JWT
910    /// claim / limit service). Map of profile name → `{ rate, burst }`.
911    #[serde(default)]
912    pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
913    /// Rate-limit rules, evaluated in order; the first whose pattern matches the
914    /// request path applies.
915    #[serde(default)]
916    pub rules: Vec<RateRuleConfig>,
917    /// Profile name applied when a matched rule resolves no other limit (JWT and
918    /// service resolution absent or empty, and the rule sets no explicit
919    /// profile). Must name an entry in `profiles`.
920    #[serde(default)]
921    pub default_profile: Option<String>,
922    /// Resolve a key's limit from claims in the validated JWT. Presence enables
923    /// JWT-based resolution (tier name or explicit numbers).
924    #[serde(default)]
925    pub jwt_limits: Option<JwtLimitConfig>,
926    /// Resolve a key's limit from an external service. The lookup is cached and
927    /// refreshed in the background; the request path never blocks on it.
928    #[serde(default)]
929    pub limit_service: Option<LimitServiceConfig>,
930    /// Asynchronous cross-instance reconciliation via a shared store. When unset,
931    /// each instance limits locally (fleet limit ≈ N × per-instance).
932    #[serde(default)]
933    pub sync: Option<SyncConfig>,
934    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
935    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
936    /// direct peer falls in one of these ranges; otherwise the peer socket
937    /// address is used as the client identity. Empty (the default) means do not
938    /// trust forwarding headers; set this behind a load balancer.
939    #[serde(default)]
940    pub trusted_proxies: Vec<String>,
941    /// The traffic the rules apply to. Default: `transcoded` and `endpoints`.
942    #[serde(default)]
943    pub scope: Option<ScopeConfig>,
944}
945
946/// A named limit tier: a sustained rate plus an instantaneous burst capacity.
947#[derive(Debug, Clone, Deserialize)]
948#[serde(deny_unknown_fields)]
949#[non_exhaustive]
950pub struct LimitProfileConfig {
951    /// Sustained rate as `"<count>/<unit>"` (e.g. `"100/min"`, units
952    /// `s`/`min`/`hour`) or a bare count (interpreted per minute).
953    pub rate: String,
954    /// Maximum requests admitted back-to-back before throttling to the rate.
955    /// Defaults to the per-window rate count (one full window of burst).
956    #[serde(default)]
957    pub burst: Option<u64>,
958}
959
960/// One rate-limit rule: a path pattern, how to key it, and an optional static
961/// profile. The rule's *phase* (before or after auth) is derived from its key
962/// alone: a `jwt_claim` key needs validated claims so it runs after auth; `ip`
963/// and `header` keys run before auth so anonymous floods are shed before any
964/// signature verification. (`jwt_limits` therefore only takes effect on
965/// `jwt_claim` rules, the only ones running with claims available.)
966#[derive(Debug, Clone, Deserialize)]
967#[serde(deny_unknown_fields)]
968#[non_exhaustive]
969pub struct RateRuleConfig {
970    /// Glob path pattern (`*` within a segment, `**` across segments).
971    pub pattern: String,
972    /// How to derive the limit key (who is limited). Defaults to client IP.
973    #[serde(default)]
974    pub key: KeySourceConfig,
975    /// Static profile name for this rule, used when JWT/service resolution does
976    /// not apply or yields nothing. Must name an entry in `profiles`.
977    #[serde(default)]
978    pub profile: Option<String>,
979}
980
981/// How a rule derives its limit key. All sources fall back to the client IP when
982/// their value is absent, so a limit can't be bypassed by omitting a header or
983/// authenticating anonymously. Written as a tagged map, e.g.
984/// `key: { type: jwt_claim, claim: sub }`; omitting `key` defaults to `ip`.
985#[derive(Debug, Clone, Default, PartialEq, Eq)]
986#[non_exhaustive]
987pub enum KeySourceConfig {
988    /// Client IP (trusted-proxy `X-Forwarded-For` aware). `{ type: ip }`.
989    #[default]
990    Ip,
991    /// Value of a named request header (e.g. an API key).
992    /// `{ type: header, name: x-api-key }`.
993    Header {
994        /// Header whose value identifies the client.
995        name: String,
996    },
997    /// Value of a claim from the validated JWT (provider-agnostic).
998    /// `{ type: jwt_claim, claim: sub }`.
999    JwtClaim {
1000        /// Claim whose value identifies the principal.
1001        claim: String,
1002    },
1003}
1004
1005/// Flat wire form of a rule key. `deny_unknown_fields` rejects any field outside
1006/// this set, and the manual [`KeySourceConfig`] deserializer additionally rejects
1007/// a field that belongs to a *different* variant (e.g. `name` on an `ip` key), so
1008/// a copy-edit leftover can't silently downgrade the intended key source. serde
1009/// does not honour `deny_unknown_fields` on internally-tagged enums directly,
1010/// hence this intermediate.
1011#[derive(Deserialize)]
1012#[serde(deny_unknown_fields, rename_all = "snake_case")]
1013struct KeySourceRaw {
1014    #[serde(rename = "type")]
1015    kind: String,
1016    #[serde(default)]
1017    name: Option<String>,
1018    #[serde(default)]
1019    claim: Option<String>,
1020}
1021
1022impl<'de> Deserialize<'de> for KeySourceConfig {
1023    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1024    where
1025        D: serde::Deserializer<'de>,
1026    {
1027        use serde::de::Error;
1028        let raw = KeySourceRaw::deserialize(deserializer)?;
1029        match raw.kind.as_str() {
1030            "ip" => {
1031                if raw.name.is_some() || raw.claim.is_some() {
1032                    return Err(D::Error::custom("key type 'ip' takes no other fields"));
1033                }
1034                Ok(Self::Ip)
1035            }
1036            "header" => {
1037                if raw.claim.is_some() {
1038                    return Err(D::Error::custom(
1039                        "key type 'header' takes 'name', not 'claim'",
1040                    ));
1041                }
1042                let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
1043                Ok(Self::Header { name })
1044            }
1045            "jwt_claim" => {
1046                if raw.name.is_some() {
1047                    return Err(D::Error::custom(
1048                        "key type 'jwt_claim' takes 'claim', not 'name'",
1049                    ));
1050                }
1051                let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
1052                Ok(Self::JwtClaim { claim })
1053            }
1054            other => Err(D::Error::unknown_variant(
1055                other,
1056                &["ip", "header", "jwt_claim"],
1057            )),
1058        }
1059    }
1060}
1061
1062/// Claims that carry a key's limit inside the JWT itself. A tier-name claim maps
1063/// to a `profiles` entry (numbers stay tunable in config); direct numeric claims
1064/// set the limit explicitly.
1065#[derive(Debug, Clone, Deserialize)]
1066#[serde(deny_unknown_fields)]
1067#[non_exhaustive]
1068pub struct JwtLimitConfig {
1069    /// Claim naming a profile tier (e.g. `"premium"`). Default: `ratelimit_tier`.
1070    #[serde(default = "default_tier_claim")]
1071    pub tier_claim: String,
1072    /// Claim carrying an explicit sustained rate, requests per minute. Default:
1073    /// `ratelimit_rpm`.
1074    #[serde(default = "default_rpm_claim")]
1075    pub rpm_claim: String,
1076    /// Claim carrying an explicit burst capacity. Default: `ratelimit_burst`.
1077    #[serde(default = "default_burst_claim")]
1078    pub burst_claim: String,
1079}
1080
1081fn default_tier_claim() -> String {
1082    "ratelimit_tier".to_string()
1083}
1084fn default_rpm_claim() -> String {
1085    "ratelimit_rpm".to_string()
1086}
1087fn default_burst_claim() -> String {
1088    "ratelimit_burst".to_string()
1089}
1090
1091/// External limit-resolution service. The response names a tier or gives explicit
1092/// numbers; results are cached and refreshed asynchronously, never on the request
1093/// path.
1094#[derive(Debug, Clone, Deserialize)]
1095#[serde(deny_unknown_fields)]
1096#[non_exhaustive]
1097pub struct LimitServiceConfig {
1098    /// HTTP endpoint queried with the limit key; returns `{ tier }` or
1099    /// `{ rate_per_min, burst }`.
1100    pub endpoint: String,
1101    /// How long a resolved limit is cached before a background refresh, in
1102    /// seconds (default: 300).
1103    #[serde(default = "default_limit_ttl_secs")]
1104    pub ttl_secs: u64,
1105    /// Timeout for the background fetch, in milliseconds (default: 500).
1106    #[serde(default = "default_limit_timeout_ms")]
1107    pub timeout_ms: u64,
1108}
1109
1110fn default_limit_ttl_secs() -> u64 {
1111    300
1112}
1113fn default_limit_timeout_ms() -> u64 {
1114    500
1115}
1116
1117/// Asynchronous cross-instance reconciliation via a shared store.
1118#[derive(Debug, Clone, Deserialize)]
1119#[serde(deny_unknown_fields)]
1120#[non_exhaustive]
1121pub struct SyncConfig {
1122    /// Shared-store URL (e.g. `"redis://127.0.0.1/"`). Requires the `redis` build
1123    /// feature; without it the proxy logs a warning and stays local-only.
1124    pub redis_url: String,
1125    /// Background push/pull interval in milliseconds (default: 500). The
1126    /// worst-case fleet overshoot is bounded by `(N-1) × rate × interval`.
1127    #[serde(default = "default_sync_interval_ms")]
1128    pub interval_ms: u64,
1129}
1130
1131fn default_sync_interval_ms() -> u64 {
1132    500
1133}
1134
1135/// OIDC discovery config.
1136#[derive(Debug, Clone, Deserialize)]
1137#[non_exhaustive]
1138pub struct OidcDiscoveryConfig {
1139    #[serde(default)]
1140    pub enabled: bool,
1141    pub issuer: String,
1142    #[serde(default)]
1143    pub authorization_endpoint: Option<String>,
1144    #[serde(default)]
1145    pub token_endpoint: Option<String>,
1146    #[serde(default)]
1147    pub userinfo_endpoint: Option<String>,
1148    #[serde(default)]
1149    pub jwks_uri: Option<String>,
1150    #[serde(default)]
1151    pub signing_key: Option<SigningKeyConfig>,
1152}
1153
1154/// Signing key config for JWKS endpoint.
1155#[derive(Debug, Clone, Deserialize)]
1156#[non_exhaustive]
1157pub struct SigningKeyConfig {
1158    #[serde(default = "default_algorithm")]
1159    pub algorithm: String,
1160    pub public_key_pem_file: PathBuf,
1161}
1162
1163fn default_algorithm() -> String {
1164    "EdDSA".into()
1165}
1166
1167/// Health-probe endpoint configuration.
1168///
1169/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
1170/// `/internal/` prefix) or disable them when a fronting platform supplies its
1171/// own. Defaults match the conventional `/health*` layout.
1172#[derive(Debug, Clone, Deserialize)]
1173#[non_exhaustive]
1174pub struct HealthConfig {
1175    /// Mount the health endpoints. Default: true.
1176    #[serde(default = "default_true")]
1177    pub enabled: bool,
1178    /// Aggregate health endpoint. Default: `/health`.
1179    #[serde(default = "default_health_path")]
1180    pub path: String,
1181    /// Liveness probe. Default: `/health/live`.
1182    #[serde(default = "default_health_live_path")]
1183    pub live_path: String,
1184    /// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
1185    #[serde(default = "default_health_ready_path")]
1186    pub ready_path: String,
1187    /// Startup probe. Default: `/health/startup`.
1188    #[serde(default = "default_health_startup_path")]
1189    pub startup_path: String,
1190}
1191
1192fn default_health_path() -> String {
1193    "/health".into()
1194}
1195fn default_health_live_path() -> String {
1196    "/health/live".into()
1197}
1198fn default_health_ready_path() -> String {
1199    "/health/ready".into()
1200}
1201fn default_health_startup_path() -> String {
1202    "/health/startup".into()
1203}
1204
1205impl Default for HealthConfig {
1206    fn default() -> Self {
1207        Self {
1208            enabled: true,
1209            path: default_health_path(),
1210            live_path: default_health_live_path(),
1211            ready_path: default_health_ready_path(),
1212            startup_path: default_health_startup_path(),
1213        }
1214    }
1215}
1216
1217/// Prometheus metrics endpoint configuration.
1218#[derive(Debug, Clone, Deserialize)]
1219#[non_exhaustive]
1220pub struct MetricsConfig {
1221    /// Mount the metrics endpoint. Default: true.
1222    #[serde(default = "default_true")]
1223    pub enabled: bool,
1224    /// Scrape path. Default: `/metrics`.
1225    #[serde(default = "default_metrics_path")]
1226    pub path: String,
1227}
1228
1229fn default_metrics_path() -> String {
1230    "/metrics".into()
1231}
1232
1233impl Default for MetricsConfig {
1234    fn default() -> Self {
1235        Self {
1236            enabled: true,
1237            path: default_metrics_path(),
1238        }
1239    }
1240}
1241
1242/// Maintenance mode config.
1243#[derive(Debug, Clone, Deserialize)]
1244#[non_exhaustive]
1245pub struct MaintenanceConfig {
1246    #[serde(default)]
1247    pub enabled: bool,
1248    /// Paths exempt from maintenance mode (glob patterns).
1249    #[serde(default = "default_exempt_paths")]
1250    pub exempt_paths: Vec<String>,
1251    #[serde(default = "default_maintenance_message")]
1252    pub message: String,
1253    /// The traffic maintenance mode turns away. Default: `transcoded` and
1254    /// `endpoints`.
1255    #[serde(default)]
1256    pub scope: Option<ScopeConfig>,
1257}
1258
1259fn default_exempt_paths() -> Vec<String> {
1260    vec![
1261        "/health/**".into(),
1262        "/.well-known/**".into(),
1263        "/metrics".into(),
1264        "/auth/verify".into(),
1265    ]
1266}
1267
1268fn default_maintenance_message() -> String {
1269    "Service is under maintenance. Please try again later.".into()
1270}
1271
1272impl Default for MaintenanceConfig {
1273    fn default() -> Self {
1274        Self {
1275            enabled: false,
1276            exempt_paths: default_exempt_paths(),
1277            message: default_maintenance_message(),
1278            scope: None,
1279        }
1280    }
1281}
1282
1283/// CORS configuration.
1284#[derive(Debug, Clone, Deserialize)]
1285#[non_exhaustive]
1286pub struct CorsConfig {
1287    /// Allowed origins. Empty = permissive (dev mode).
1288    #[serde(default)]
1289    pub origins: Vec<String>,
1290    /// Response headers a browser script may read on top of the ones the
1291    /// proxy always exposes (`grpc-status`, `grpc-message`,
1292    /// `grpc-status-details-bin` and the rate-limit headers): typically
1293    /// upstream metadata forwarded as a header, such as `x-request-id`.
1294    #[serde(default)]
1295    pub expose_headers: Vec<String>,
1296    /// How long a browser may cache a preflight answer, in seconds. Unset,
1297    /// the browser's own default applies.
1298    #[serde(default)]
1299    pub max_age_secs: Option<u64>,
1300    /// Apply this policy to gRPC-Web calls that pass through to the upstream
1301    /// and to their preflights, so a browser gets one policy for both (on by
1302    /// default). Off only for an upstream that sets CORS on gRPC-Web itself:
1303    /// its preflights then reach the upstream as well.
1304    #[serde(default = "default_true")]
1305    pub grpc_web: bool,
1306}
1307
1308impl Default for CorsConfig {
1309    fn default() -> Self {
1310        Self {
1311            origins: Vec::new(),
1312            expose_headers: Vec::new(),
1313            max_age_secs: None,
1314            grpc_web: true,
1315        }
1316    }
1317}
1318
1319/// Logging configuration.
1320#[derive(Debug, Clone, Deserialize)]
1321#[non_exhaustive]
1322pub struct LoggingConfig {
1323    #[serde(default = "default_log_level")]
1324    pub level: String,
1325    #[serde(default = "default_log_format")]
1326    pub format: String,
1327}
1328
1329fn default_log_level() -> String {
1330    "info".into()
1331}
1332fn default_log_format() -> String {
1333    "json".into()
1334}
1335
1336impl Default for LoggingConfig {
1337    fn default() -> Self {
1338        Self {
1339            level: default_log_level(),
1340            format: default_log_format(),
1341        }
1342    }
1343}
1344
1345/// Metrics endpoint classification.
1346#[derive(Debug, Clone, Deserialize)]
1347#[non_exhaustive]
1348pub struct MetricsClassConfig {
1349    /// Glob pattern for path matching.
1350    pub pattern: String,
1351    /// Label value for this class.
1352    pub class: String,
1353}
1354
1355impl ProxyConfig {
1356    /// Load configuration from a YAML file.
1357    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1358        Self::from_yaml_str(&std::fs::read_to_string(path)?)
1359    }
1360
1361    /// Parse configuration from a YAML string.
1362    ///
1363    /// Useful for embedding the proxy: load a baked-in config (e.g. via
1364    /// `include_str!`) without touching the filesystem.
1365    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1366        let config: Self = serde_yaml::from_str(yaml)?;
1367        config.validate()?;
1368        Ok(config)
1369    }
1370
1371    /// Validate cross-field constraints that the type system can't express.
1372    ///
1373    /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
1374    /// directly when building a [`ProxyConfig`] programmatically so the same
1375    /// invariants are enforced on the embedded path.
1376    pub fn validate(&self) -> anyhow::Result<()> {
1377        if self.streaming.sse_keep_alive_secs == 0 {
1378            anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1379        }
1380        if self.grpc_web.translate && !self.cors.grpc_web {
1381            anyhow::bail!(
1382                "grpc_web.translate needs cors.grpc_web: an upstream that speaks only \
1383                 gRPC cannot answer a browser's preflight for a gRPC-Web call"
1384            );
1385        }
1386        self.validate_edge_paths()?;
1387        Ok(())
1388    }
1389
1390    /// Reject malformed or duplicate built-in edge paths up front, so the router
1391    /// does not panic at construction (axum rejects a route that does not start
1392    /// with `/`, and panics on a path registered twice, e.g. setting
1393    /// `health.path` to the default `live_path`).
1394    fn validate_edge_paths(&self) -> anyhow::Result<()> {
1395        let mut seen = std::collections::HashSet::new();
1396        let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1397            if !path.starts_with('/') {
1398                anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1399            }
1400            if !seen.insert(path.to_string()) {
1401                anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1402            }
1403            Ok(())
1404        };
1405        if self.health.enabled {
1406            check("health.path", &self.health.path)?;
1407            check("health.live_path", &self.health.live_path)?;
1408            check("health.ready_path", &self.health.ready_path)?;
1409            check("health.startup_path", &self.health.startup_path)?;
1410        }
1411        if self.metrics.enabled {
1412            check("metrics.path", &self.metrics.path)?;
1413        }
1414        if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1415            check("openapi.path", &openapi.path)?;
1416            check("openapi.docs_path", &openapi.docs_path)?;
1417        }
1418        Ok(())
1419    }
1420
1421    /// Parse rate string like "20/min" → requests per window.
1422    pub fn parse_rate(rate: &str) -> Option<u32> {
1423        let parts: Vec<&str> = rate.split('/').collect();
1424        if parts.len() != 2 {
1425            return None;
1426        }
1427        parts[0].trim().parse().ok()
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests;