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