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;