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/// Every config type is `#[non_exhaustive]`, so a new setting is not a
12/// breaking change. Build one in code from its `Default` (or its constructor
13/// when a field has no default) and set the fields you need, or use the
14/// `ProxyServer::with_*` builder methods.
15///
16/// # Examples
17///
18/// ```
19/// use structured_proxy::config::{DescriptorSource, ProxyConfig, UpstreamConfig};
20///
21/// static DESCRIPTORS: &[u8] = &[];
22/// let mut config = ProxyConfig::default();
23/// config.upstream = Some(UpstreamConfig::new("http://127.0.0.1:50051"));
24/// config.descriptors = vec![DescriptorSource::Embedded { bytes: DESCRIPTORS }];
25/// config.service.name = "embedded".into();
26/// config.validate().unwrap();
27/// ```
28#[derive(Debug, Clone, Deserialize)]
29#[non_exhaustive]
30pub struct ProxyConfig {
31 /// The remote gRPC upstream. Required by the standalone proxy and by
32 /// [`ProxyServer::upstream`](crate::ProxyServer::upstream); an embedder
33 /// whose upstream is in process
34 /// ([`ProxyServer::service`](crate::ProxyServer::service)) leaves it out.
35 #[serde(default)]
36 pub upstream: Option<UpstreamConfig>,
37
38 /// Proto descriptor sources.
39 #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
40 pub descriptors: Vec<DescriptorSource>,
41
42 /// Listen addresses.
43 #[serde(default)]
44 pub listen: ListenConfig,
45
46 /// Service identity (for health endpoint, metrics namespace).
47 #[serde(default)]
48 pub service: ServiceConfig,
49
50 /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
51 #[serde(default)]
52 pub aliases: Vec<AliasConfig>,
53
54 /// OpenAPI generation.
55 #[serde(default)]
56 pub openapi: Option<OpenApiConfig>,
57
58 /// Auth configuration (JWT, forward auth, AuthZ).
59 #[serde(default)]
60 pub auth: Option<AuthConfig>,
61
62 /// Rate limiting (Shield).
63 #[serde(default)]
64 pub shield: Option<ShieldConfig>,
65
66 /// OIDC discovery (optional — for IdP proxies).
67 #[serde(default)]
68 pub oidc_discovery: Option<OidcDiscoveryConfig>,
69
70 /// Health-probe endpoints (paths configurable; can be disabled).
71 #[serde(default)]
72 pub health: HealthConfig,
73
74 /// Prometheus metrics endpoint (path configurable; can be disabled).
75 #[serde(default)]
76 pub metrics: MetricsConfig,
77
78 /// Maintenance mode.
79 #[serde(default)]
80 pub maintenance: MaintenanceConfig,
81
82 /// CORS configuration.
83 #[serde(default)]
84 pub cors: CorsConfig,
85
86 /// Logging.
87 #[serde(default)]
88 pub logging: LoggingConfig,
89
90 /// Metrics endpoint classification (path patterns → class labels).
91 #[serde(default)]
92 pub metrics_classes: Vec<MetricsClassConfig>,
93
94 /// Headers to forward from HTTP to gRPC metadata. `x-forwarded-for`,
95 /// `x-real-ip`, `forwarded` and the headers named in
96 /// `client_address.forward` carry what that forwarding policy wrote,
97 /// listed or not.
98 #[serde(default = "default_forwarded_headers")]
99 pub forwarded_headers: Vec<String>,
100
101 /// Server-streaming response behavior.
102 #[serde(default)]
103 pub streaming: StreamingConfig,
104
105 /// Request concurrency limit.
106 #[serde(default)]
107 pub concurrency: Option<ConcurrencyConfig>,
108
109 /// How gRPC-Web calls reach the upstream.
110 #[serde(default)]
111 pub grpc_web: GrpcWebConfig,
112
113 /// Which annotated RPCs are transcoded.
114 #[serde(default)]
115 pub transcode: TranscodeConfig,
116
117 /// How the client's address is resolved: the proxies trusted to report
118 /// it, and the header they report it in.
119 #[serde(default)]
120 pub client_address: ClientAddressConfig,
121}
122
123impl Default for ProxyConfig {
124 /// The configuration of an empty YAML file: health and metrics endpoints
125 /// on, everything else off.
126 fn default() -> Self {
127 Self {
128 upstream: None,
129 descriptors: Vec::new(),
130 listen: ListenConfig::default(),
131 service: ServiceConfig::default(),
132 aliases: Vec::new(),
133 openapi: None,
134 auth: None,
135 shield: None,
136 oidc_discovery: None,
137 health: HealthConfig::default(),
138 metrics: MetricsConfig::default(),
139 maintenance: MaintenanceConfig::default(),
140 cors: CorsConfig::default(),
141 logging: LoggingConfig::default(),
142 metrics_classes: Vec::new(),
143 forwarded_headers: default_forwarded_headers(),
144 streaming: StreamingConfig::default(),
145 concurrency: None,
146 grpc_web: GrpcWebConfig::default(),
147 transcode: TranscodeConfig::default(),
148 client_address: ClientAddressConfig::default(),
149 }
150 }
151}
152
153/// How the client's address is resolved, for every consumer of it at once
154/// (the rate limits, the hooks, the fallback, the upstream), and what the
155/// upstream receives of it.
156///
157/// ```yaml
158/// client_address:
159/// trusted_proxies: ["10.0.0.0/8", "192.0.2.10"]
160/// header: x_forwarded_for
161/// required: false
162/// forward:
163/// x_forwarded_for: verified
164/// client_header: x-real-ip
165/// audit_header: null
166/// ```
167///
168/// The connection's peer is the client unless it is a trusted proxy; only
169/// then is `header` read, so an address a client asserts itself never
170/// counts. The defaults trust no proxy: the right setting for a proxy that
171/// faces its clients directly. Resolution and [`forward`](Self::forward) are
172/// independent: the rate limits and the hooks always use the resolved
173/// address, whatever is forwarded.
174#[derive(Debug, Clone, Default, Deserialize)]
175#[serde(deny_unknown_fields)]
176#[non_exhaustive]
177pub struct ClientAddressConfig {
178 /// Proxies trusted to report the client's address: CIDR ranges, or bare
179 /// addresses for one host. An IPv4-mapped IPv6 peer (`::ffff:10.0.0.1`)
180 /// matches its IPv4 range. An entry that is neither stops the proxy at
181 /// startup.
182 #[serde(default)]
183 pub trusted_proxies: Vec<String>,
184 /// The header trusted proxies report the client's address in.
185 #[serde(default)]
186 pub header: ForwardingHeader,
187 /// Refuse a request whose client address cannot be resolved: no
188 /// connection information (`INTERNAL`, 500), or a trusted proxy's report
189 /// that cannot be read (`INVALID_ARGUMENT`, 400). Off: such a request
190 /// goes on with no address.
191 #[serde(default)]
192 pub required: bool,
193 /// What the upstream receives of the client's address.
194 #[serde(default)]
195 pub forward: ForwardConfig,
196}
197
198/// What the upstream (and the fallback) receives of the client's address.
199///
200/// The defaults give an upstream nothing a client can forge: a verified
201/// `X-Forwarded-For` whose first element is the client, and the address in
202/// `X-Real-IP`. Every header named here is written by the proxy alone; a
203/// guard (JWT claim headers, ext_authz, the auth decider) cannot set it.
204///
205/// | Behind | Setting |
206/// |---|---|
207/// | nginx, `$proxy_add_x_forwarded_for` and `X-Real-IP` | `x_forwarded_for: append`, `client_header: x-real-ip` |
208/// | Envoy, `use_remote_address` | `append`, `client_header: x-envoy-external-address` |
209/// | HAProxy, `option forwardfor` | `append` |
210/// | AWS ALB `append` / `preserve` / `remove` | the mode of the same name |
211/// | Cloudflare | `append`, `client_header: cf-connecting-ip` |
212/// | Traefik, Caddy | `verified` (stricter), or `append` |
213#[derive(Debug, Clone, Deserialize)]
214#[serde(deny_unknown_fields)]
215#[non_exhaustive]
216pub struct ForwardConfig {
217 /// What `X-Forwarded-For` carries upstream.
218 #[serde(default)]
219 pub x_forwarded_for: XForwardedFor,
220 /// The header the resolved address is written to, replacing any value
221 /// the request carried; absent when no address resolved. `None` (`null`
222 /// in YAML) writes none. A text gRPC metadata key (not ending in `-bin`)
223 /// other than `x-forwarded-for` and `forwarded`. Default: `x-real-ip`.
224 #[serde(default = "default_client_header")]
225 pub client_header: Option<String>,
226 /// A header receiving the `X-Forwarded-For` field lines exactly as the
227 /// request carried them, before any change: for logs and audits only,
228 /// since nothing in it is verified. Off by default. A text gRPC metadata
229 /// key (not ending in `-bin`) other than `x-forwarded-for`, `x-real-ip`,
230 /// `forwarded` and `client_header`, e.g. `x-original-forwarded-for`.
231 #[serde(default)]
232 pub audit_header: Option<String>,
233}
234
235fn default_client_header() -> Option<String> {
236 Some("x-real-ip".into())
237}
238
239impl Default for ForwardConfig {
240 fn default() -> Self {
241 Self {
242 x_forwarded_for: XForwardedFor::default(),
243 client_header: default_client_header(),
244 audit_header: None,
245 }
246 }
247}
248
249/// What `X-Forwarded-For` carries upstream. `X-Real-IP` and `Forwarded`
250/// (RFC 7239) from the request reach the upstream only with `preserve`; in
251/// every other mode they are removed, since they would contradict it.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
253#[serde(rename_all = "snake_case")]
254#[non_exhaustive]
255pub enum XForwardedFor {
256 /// The verified part of the chain: the resolved client, the trusted
257 /// proxies between it and this proxy, then this proxy's peer
258 /// (`203.0.113.7, 10.0.0.2, 10.0.0.1`). Whatever the client wrote left of
259 /// its own address is dropped, so the first element is always the
260 /// client, and an upstream walking the list from the right reaches the
261 /// same client. Absent when no address resolved.
262 #[default]
263 Verified,
264 /// The resolved address alone; absent when none resolved.
265 Resolved,
266 /// The request's list as it arrived, then this proxy's peer, as nginx,
267 /// Envoy, HAProxy and most load balancers append. Its first element is
268 /// whatever the client wrote.
269 Append,
270 /// The request's `X-Forwarded-For`, `X-Real-IP` and `Forwarded` as they
271 /// arrived, nothing added. Its first element is whatever the client
272 /// wrote.
273 Preserve,
274 /// No `X-Forwarded-For`.
275 Remove,
276}
277
278/// The header trusted proxies report the client's address in.
279#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
280#[serde(rename_all = "snake_case")]
281#[non_exhaustive]
282pub enum ForwardingHeader {
283 /// `X-Forwarded-For`, every field line in order, read right to left
284 /// through the trusted proxies to the first address outside them.
285 #[default]
286 XForwardedFor,
287 /// `X-Real-IP`: one address, the trusted proxy's own peer.
288 XRealIp,
289 /// A cleaned report from a proxy that already resolved the client:
290 /// `X-Real-IP` with one address and, if present at all, an
291 /// `X-Forwarded-For` of that same one address, with no `Forwarded`. No
292 /// chain is walked; a report that disagrees with itself is invalid.
293 CleanedReport,
294}
295
296/// Which annotated RPCs are transcoded.
297///
298/// ```yaml
299/// transcode:
300/// only: ["acme.v1.Orders", "acme.v1.Users/GetUser"]
301/// ```
302#[derive(Debug, Clone, Default, Deserialize)]
303#[serde(deny_unknown_fields)]
304#[non_exhaustive]
305pub struct TranscodeConfig {
306 /// Services (`package.Service`) and single methods
307 /// (`package.Service/Method`) whose `google.api.http` rules become REST
308 /// routes; empty transcodes every annotated RPC. A name the descriptors do
309 /// not hold stops the proxy at startup.
310 #[serde(default)]
311 pub only: Vec<String>,
312}
313
314/// One config section from YAML, the way the config file reads it: for the
315/// sections an embedder hands to a builder method, such as
316/// [`ProxyServer::with_rate_limits`](crate::ProxyServer::with_rate_limits).
317///
318/// # Errors
319///
320/// YAML that is not that section.
321///
322/// # Examples
323///
324/// ```
325/// use structured_proxy::config::{self, ShieldConfig};
326///
327/// let shield: ShieldConfig = config::from_yaml(
328/// "enabled: true\nprofiles:\n anon: { rate: \"60/min\" }\nrules:\n - pattern: \"/**\"\n key: { type: ip }\n profile: anon\n",
329/// )
330/// .unwrap();
331/// assert!(shield.enabled);
332/// ```
333pub fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> anyhow::Result<T> {
334 Ok(parse_yaml(yaml)?)
335}
336
337/// The YAML reader of every config path, with the YAML 1.2 core schema's
338/// types: a boolean is `true` or `false` in any letter case (`True`, `TRUE`),
339/// while `yes`, `no`, `on` and `off` are refused; an unquoted number or
340/// boolean where text is expected is an error, not the text it spells, so
341/// `name: true` does not start a service called "true". Errors stay on one
342/// line, without a snippet of the document, so a log record holds the whole
343/// message.
344pub(crate) fn parse_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> Result<T, YamlError> {
345 let mut path = None;
346 serde_saphyr::with_deserializer_from_str_with_options(
347 yaml,
348 serde_saphyr::options! {
349 // Matches `true`/`false` ignoring case, the core schema's spellings.
350 strict_booleans: true,
351 no_schema: true,
352 with_snippet: false,
353 },
354 |de| {
355 serde_path_to_error::deserialize(de).map_err(|e| {
356 if e.path().iter().next().is_some() {
357 path = Some(e.path().to_string());
358 }
359 e.into_inner()
360 })
361 },
362 )
363 .map_err(|e| {
364 YamlError(match path {
365 Some(path) => format!("{path}: {e}"),
366 // The document as a whole is at fault (not YAML, not a map).
367 None => e.to_string(),
368 })
369 })
370}
371
372/// A YAML document that does not read as the config it should be, naming the
373/// key at fault the way the document spells its path (`runtime.worker_threads`).
374#[derive(Debug, thiserror::Error)]
375#[error("{0}")]
376pub(crate) struct YamlError(String);
377
378/// How gRPC-Web calls reach the upstream.
379#[derive(Debug, Clone, Default, Deserialize)]
380#[serde(deny_unknown_fields)]
381#[non_exhaustive]
382pub struct GrpcWebConfig {
383 /// Translate gRPC-Web (binary and text, over HTTP/1.1 too) to gRPC for an
384 /// upstream that speaks only gRPC. Off: gRPC-Web passes through, for an
385 /// upstream that translates it itself. Needs the proxy's CORS policy on
386 /// gRPC-Web (`cors.grpc_web`), since the upstream then cannot answer a
387 /// browser's preflight.
388 #[serde(default)]
389 pub translate: bool,
390}
391
392fn default_forwarded_headers() -> Vec<String> {
393 vec![
394 "authorization".into(),
395 "dpop".into(),
396 "x-request-id".into(),
397 "x-forwarded-proto".into(),
398 "accept-language".into(),
399 "user-agent".into(),
400 "idempotency-key".into(),
401 ]
402}
403
404/// Server-streaming response behavior.
405///
406/// Server-streaming RPCs are exposed as NDJSON by default and as Server-Sent
407/// Events when the client sends `Accept: text/event-stream`. The keep-alive
408/// interval applies only to the SSE path.
409#[derive(Debug, Clone, Deserialize)]
410#[non_exhaustive]
411pub struct StreamingConfig {
412 /// SSE keep-alive interval in seconds. Comment frames are emitted on idle
413 /// streams to keep intermediaries (load balancers, nginx) from closing the
414 /// connection on read timeout. Default: 15.
415 #[serde(default = "default_sse_keep_alive_secs")]
416 pub sse_keep_alive_secs: u64,
417}
418
419fn default_sse_keep_alive_secs() -> u64 {
420 15
421}
422
423impl Default for StreamingConfig {
424 fn default() -> Self {
425 Self {
426 sse_keep_alive_secs: default_sse_keep_alive_secs(),
427 }
428 }
429}
430
431/// Transcoding settings read from the same YAML document as [`ProxyConfig`] by
432/// [`ProxyServer::from_yaml_str`](crate::ProxyServer::from_yaml_str) and
433/// [`ProxyServer::from_file`](crate::ProxyServer::from_file):
434///
435/// ```yaml
436/// error_details:
437/// enabled: true
438/// opaque: false
439/// routes:
440/// - pattern: "/v1/admin/**"
441/// enabled: false
442/// - pattern: "/v1/partner/**"
443/// opaque: true
444/// streaming:
445/// ndjson_envelope: true
446/// response_headers:
447/// deny: ["x-debug-trace"]
448/// ```
449///
450/// They live outside [`ProxyConfig`] so that embedders who build it as a
451/// struct literal are not affected.
452#[derive(Debug, Default, Deserialize)]
453pub(crate) struct TranscodeFileConfig {
454 #[serde(default)]
455 error_details: Option<ErrorDetailsFileConfig>,
456 #[serde(default)]
457 streaming: StreamingFileConfig,
458 #[serde(default)]
459 response_headers: Option<ResponseHeadersFileConfig>,
460}
461
462/// `response_headers:`. A typo here would silently let an internal header
463/// reach clients, so unknown keys are rejected.
464#[derive(Debug, Deserialize)]
465#[serde(deny_unknown_fields)]
466struct ResponseHeadersFileConfig {
467 /// Upstream response metadata keys kept off the HTTP response.
468 #[serde(default)]
469 deny: Vec<String>,
470}
471
472/// `error_details:`. A typo here would silently change what clients learn
473/// about server-side failures, so unknown keys are rejected.
474#[derive(Debug, Deserialize)]
475#[serde(deny_unknown_fields)]
476struct ErrorDetailsFileConfig {
477 /// Default for routes no rule matches.
478 #[serde(default = "default_true")]
479 enabled: bool,
480 /// Default for routes no rule matches: whether details of types no
481 /// descriptor describes go to `opaqueDetails` instead of being withheld.
482 #[serde(default)]
483 opaque: bool,
484 /// Per-route overrides; for each switch, the first matching rule that
485 /// sets it wins.
486 #[serde(default)]
487 routes: Vec<ErrorDetailsRouteFileConfig>,
488}
489
490/// A rule sets `enabled`, `opaque` or both; one setting neither is rejected.
491#[derive(Debug, Deserialize)]
492#[serde(deny_unknown_fields)]
493struct ErrorDetailsRouteFileConfig {
494 pattern: String,
495 #[serde(default)]
496 enabled: Option<bool>,
497 #[serde(default)]
498 opaque: Option<bool>,
499}
500
501/// The `streaming:` keys [`StreamingConfig`] does not hold. Both read the same
502/// section, so neither can reject the other's keys; [`unknown_config_keys`]
503/// reports the keys neither reads.
504#[derive(Debug, Default, Deserialize)]
505struct StreamingFileConfig {
506 #[serde(default)]
507 ndjson_envelope: bool,
508}
509
510/// Every top-level key a config file may use: the [`ProxyConfig`] fields, the
511/// transcoding settings kept outside it, and `runtime`, which the standalone
512/// binary reads to build its async runtime (an embedder brings its own).
513pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
514 "upstream",
515 "descriptors",
516 "listen",
517 "service",
518 "aliases",
519 "openapi",
520 "auth",
521 "shield",
522 "oidc_discovery",
523 "health",
524 "metrics",
525 "maintenance",
526 "cors",
527 "logging",
528 "metrics_classes",
529 "forwarded_headers",
530 "streaming",
531 "error_details",
532 "response_headers",
533 "runtime",
534 "concurrency",
535 "grpc_web",
536 "transcode",
537 "client_address",
538];
539
540/// Every `streaming:` key: the [`StreamingConfig`] fields plus the ones
541/// [`StreamingFileConfig`] reads.
542pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
543
544/// The keys of `yaml` that no setting reads, typically typos (`error_detail:`
545/// for `error_details:`) that would otherwise leave a default silently in
546/// force. They are reported, not rejected, so a file that loaded before keeps
547/// loading.
548pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
549 let Ok(ConfigKeys { top, streaming }) = parse_yaml(yaml) else {
550 return Vec::new();
551 };
552 let mut unknown: Vec<String> = top
553 .into_iter()
554 .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()))
555 .collect();
556 // `streaming:` is split between StreamingConfig and StreamingFileConfig,
557 // so neither can deny unknown fields; its keys are checked here instead.
558 unknown.extend(
559 streaming
560 .iter()
561 .filter(|key| !KNOWN_STREAMING_KEYS.contains(&key.as_str()))
562 .map(|key| format!("streaming.{key}")),
563 );
564 unknown
565}
566
567/// The keys of a config document in their order: the top-level ones and those
568/// of `streaming:`. A document that is empty or not a map has none.
569#[derive(Default)]
570struct ConfigKeys {
571 top: Vec<String>,
572 streaming: Vec<String>,
573}
574
575impl<'de> Deserialize<'de> for ConfigKeys {
576 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
577 struct Visitor;
578 impl<'de> serde::de::Visitor<'de> for Visitor {
579 type Value = ConfigKeys;
580 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581 f.write_str("a config map")
582 }
583 fn visit_unit<E>(self) -> Result<ConfigKeys, E> {
584 Ok(ConfigKeys::default())
585 }
586 fn visit_none<E>(self) -> Result<ConfigKeys, E> {
587 Ok(ConfigKeys::default())
588 }
589 fn visit_map<A: serde::de::MapAccess<'de>>(
590 self,
591 mut map: A,
592 ) -> Result<ConfigKeys, A::Error> {
593 let mut keys = ConfigKeys::default();
594 while let Some(TextKey(key)) = map.next_key()? {
595 match key {
596 Some(key) if key == "streaming" => {
597 keys.streaming = map.next_value::<MapKeys>()?.0;
598 keys.top.push(key);
599 }
600 Some(key) => {
601 map.next_value::<serde::de::IgnoredAny>()?;
602 keys.top.push(key);
603 }
604 None => {
605 map.next_value::<serde::de::IgnoredAny>()?;
606 }
607 }
608 }
609 Ok(keys)
610 }
611 }
612 de.deserialize_any(Visitor)
613 }
614}
615
616/// The keys of a map in their order; an empty value has none.
617struct MapKeys(Vec<String>);
618
619impl<'de> Deserialize<'de> for MapKeys {
620 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
621 struct Visitor;
622 impl<'de> serde::de::Visitor<'de> for Visitor {
623 type Value = MapKeys;
624 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625 f.write_str("a map")
626 }
627 fn visit_unit<E>(self) -> Result<MapKeys, E> {
628 Ok(MapKeys(Vec::new()))
629 }
630 fn visit_none<E>(self) -> Result<MapKeys, E> {
631 Ok(MapKeys(Vec::new()))
632 }
633 fn visit_map<A: serde::de::MapAccess<'de>>(
634 self,
635 mut map: A,
636 ) -> Result<MapKeys, A::Error> {
637 let mut keys = Vec::new();
638 while let Some(TextKey(key)) = map.next_key()? {
639 map.next_value::<serde::de::IgnoredAny>()?;
640 keys.extend(key);
641 }
642 Ok(MapKeys(keys))
643 }
644 }
645 de.deserialize_any(Visitor)
646 }
647}
648
649/// A map key as text; `None` for a key that is a number or a boolean, which
650/// no setting is named by and which the reader refuses to take for text.
651struct TextKey(Option<String>);
652
653impl<'de> Deserialize<'de> for TextKey {
654 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
655 struct Visitor;
656 impl serde::de::Visitor<'_> for Visitor {
657 type Value = TextKey;
658 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659 f.write_str("a scalar key")
660 }
661 fn visit_str<E>(self, key: &str) -> Result<TextKey, E> {
662 Ok(TextKey(Some(key.to_owned())))
663 }
664 fn visit_string<E>(self, key: String) -> Result<TextKey, E> {
665 Ok(TextKey(Some(key)))
666 }
667 fn visit_bool<E>(self, _: bool) -> Result<TextKey, E> {
668 Ok(TextKey(None))
669 }
670 fn visit_i64<E>(self, _: i64) -> Result<TextKey, E> {
671 Ok(TextKey(None))
672 }
673 fn visit_u64<E>(self, _: u64) -> Result<TextKey, E> {
674 Ok(TextKey(None))
675 }
676 fn visit_f64<E>(self, _: f64) -> Result<TextKey, E> {
677 Ok(TextKey(None))
678 }
679 }
680 de.deserialize_any(Visitor)
681 }
682}
683
684/// The error for a config that sets `shield.trusted_proxies`, which became
685/// `client_address.trusted_proxies`: the parse error alone would name an
686/// unknown field, not where its value belongs now.
687fn moved_setting(yaml: &str) -> Option<anyhow::Error> {
688 /// The settings that moved, each `true` when the document names it.
689 #[derive(Deserialize)]
690 struct Moved {
691 #[serde(default)]
692 shield: Option<MovedFromShield>,
693 }
694 #[derive(Deserialize)]
695 struct MovedFromShield {
696 #[serde(default, deserialize_with = "named")]
697 trusted_proxies: bool,
698 }
699 /// A key that is present, whatever its value, even `null`.
700 fn named<'de, D: serde::Deserializer<'de>>(de: D) -> Result<bool, D::Error> {
701 serde::de::IgnoredAny::deserialize(de).map(|_| true)
702 }
703
704 let moved: Moved = parse_yaml(yaml).ok()?;
705 moved.shield?.trusted_proxies.then(|| {
706 anyhow::anyhow!(
707 "shield.trusted_proxies is now client_address.trusted_proxies: the client \
708 address is resolved once for the rate limits, the hooks and the upstream"
709 )
710 })
711}
712
713impl TranscodeFileConfig {
714 /// Compile into the options the transcoded routes are built with.
715 ///
716 /// # Errors
717 ///
718 /// An `error_details` route pattern that is relative or not a valid glob,
719 /// a route rule that sets neither `enabled` nor `opaque`, or a
720 /// `response_headers.deny` entry that is not a header name.
721 pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
722 use crate::transcode::error::ErrorDetailsPolicy;
723 let mut options = crate::transcode::TranscodeOptions::default()
724 .with_ndjson_envelope(self.streaming.ndjson_envelope);
725 if let Some(cfg) = &self.response_headers {
726 let deny = cfg
727 .deny
728 .iter()
729 .map(|name| {
730 http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
731 format!("response_headers.deny entry {name:?} is not a header name")
732 })
733 })
734 .collect::<Result<Vec<_>, _>>()?;
735 options = options.with_denied_response_headers(deny);
736 }
737 if let Some(cfg) = &self.error_details {
738 let base = if cfg.enabled {
739 ErrorDetailsPolicy::default()
740 } else {
741 ErrorDetailsPolicy::disabled()
742 }
743 .with_opaque_details(cfg.opaque);
744 let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
745 // A rule without a switch would match routes and change
746 // nothing, which is a config mistake, not an intent.
747 if rule.enabled.is_none() && rule.opaque.is_none() {
748 return Err(format!(
749 "error details route {:?} sets neither enabled nor opaque",
750 rule.pattern
751 ));
752 }
753 policy.rule(&rule.pattern, rule.enabled, rule.opaque)
754 })?;
755 options = options.with_error_details(policy);
756 }
757 Ok(options)
758 }
759}
760
761/// Upstream gRPC service configuration.
762#[derive(Debug, Clone, Deserialize)]
763#[non_exhaustive]
764pub struct UpstreamConfig {
765 /// gRPC upstream address (e.g., "http://localhost:4180").
766 pub default: String,
767}
768
769impl UpstreamConfig {
770 /// The upstream at `address` (e.g. `http://localhost:4180`).
771 pub fn new(address: impl Into<String>) -> Self {
772 Self {
773 default: address.into(),
774 }
775 }
776}
777
778/// Descriptor loading source.
779#[derive(Debug, Clone)]
780#[non_exhaustive]
781pub enum DescriptorSource {
782 /// Pre-compiled descriptor file.
783 File { file: PathBuf },
784 /// gRPC server reflection (development mode).
785 Reflection { reflection: String },
786 /// Embedded bytes (set programmatically, not from YAML).
787 Embedded { bytes: &'static [u8] },
788}
789
790/// Helper for YAML deserialization (only File and Reflection variants).
791#[derive(Debug, Clone, Deserialize)]
792#[serde(untagged)]
793enum DescriptorSourceYaml {
794 File { file: PathBuf },
795 Reflection { reflection: String },
796}
797
798impl From<DescriptorSourceYaml> for DescriptorSource {
799 fn from(yaml: DescriptorSourceYaml) -> Self {
800 match yaml {
801 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
802 DescriptorSourceYaml::Reflection { reflection } => {
803 DescriptorSource::Reflection { reflection }
804 }
805 }
806 }
807}
808
809fn deserialize_descriptor_sources<'de, D>(
810 deserializer: D,
811) -> std::result::Result<Vec<DescriptorSource>, D::Error>
812where
813 D: serde::Deserializer<'de>,
814{
815 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
816 Ok(yaml_sources.into_iter().map(Into::into).collect())
817}
818
819/// The listener [`ProxyServer::serve`](crate::ProxyServer::serve) runs.
820#[derive(Debug, Clone, Deserialize)]
821#[serde(deny_unknown_fields)]
822#[non_exhaustive]
823pub struct ListenConfig {
824 /// Listen address (default: "0.0.0.0:8080").
825 #[serde(default = "default_http_listen")]
826 pub http: String,
827 /// Most connections served at once; past it the next connection is
828 /// accepted when one closes. Unset: no limit.
829 #[serde(default)]
830 pub max_connections: Option<usize>,
831 /// TLS on the listener, mTLS with `client_ca_file`. Unset: cleartext.
832 #[serde(default)]
833 pub tls: Option<ListenTlsConfig>,
834 /// Seconds a connection may go without a request in flight before it is
835 /// closed (HTTP/2 gets a GOAWAY), so idle clients do not hold
836 /// `max_connections` slots. 0 keeps idle connections open. Default: 60.
837 #[serde(default = "default_idle_timeout_secs")]
838 pub idle_timeout_secs: u64,
839 /// Seconds an HTTP/1.1 client has to send a request's headers. At least
840 /// 1. Default: 30.
841 #[serde(default = "default_header_read_timeout_secs")]
842 pub header_read_timeout_secs: u64,
843 /// Seconds a graceful shutdown waits for open connections to finish what
844 /// they serve; the ones still open after it are closed. 0 waits for all.
845 /// Default: 25, below the 30 s grace period Kubernetes gives by default.
846 #[serde(default = "default_drain_timeout_secs")]
847 pub drain_timeout_secs: u64,
848}
849
850fn default_drain_timeout_secs() -> u64 {
851 25
852}
853
854fn default_idle_timeout_secs() -> u64 {
855 60
856}
857
858fn default_header_read_timeout_secs() -> u64 {
859 30
860}
861
862fn default_tls_handshake_timeout_secs() -> u64 {
863 10
864}
865
866/// TLS for the listener.
867///
868/// ```yaml
869/// tls:
870/// cert_file: /etc/proxy/tls.crt # PEM chain, leaf first
871/// key_file: /etc/proxy/tls.key # PEM private key
872/// client_ca_file: /etc/proxy/ca.crt # optional: verify client certificates
873/// client_auth: required # or `optional`
874/// ```
875#[derive(Debug, Clone, Deserialize)]
876#[serde(deny_unknown_fields)]
877#[non_exhaustive]
878pub struct ListenTlsConfig {
879 /// The server certificate chain, PEM, leaf first.
880 pub cert_file: PathBuf,
881 /// The server private key, PEM (PKCS#8, PKCS#1 or SEC1).
882 pub key_file: PathBuf,
883 /// CA certificates, PEM, that client certificates are verified against.
884 /// Unset: clients present none.
885 #[serde(default)]
886 pub client_ca_file: Option<PathBuf>,
887 /// Whether a client must present a certificate; `required` when
888 /// `client_ca_file` is set and this is not. Setting it without
889 /// `client_ca_file` is an error: nothing could verify the certificate.
890 #[serde(default)]
891 pub client_auth: Option<ClientAuth>,
892 /// Seconds a client has to finish the TLS handshake. At least 1.
893 /// Default: 10.
894 #[serde(default = "default_tls_handshake_timeout_secs")]
895 pub handshake_timeout_secs: u64,
896}
897
898impl ListenTlsConfig {
899 /// TLS with the certificate chain and private key in these PEM files, no
900 /// client certificates, and the default handshake timeout.
901 pub fn new(cert_file: impl Into<PathBuf>, key_file: impl Into<PathBuf>) -> Self {
902 Self {
903 cert_file: cert_file.into(),
904 key_file: key_file.into(),
905 client_ca_file: None,
906 client_auth: None,
907 handshake_timeout_secs: default_tls_handshake_timeout_secs(),
908 }
909 }
910}
911
912/// Whether a TLS client must present a certificate.
913#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
914#[serde(rename_all = "snake_case")]
915#[non_exhaustive]
916pub enum ClientAuth {
917 /// A client without a valid certificate is refused in the handshake.
918 #[default]
919 Required,
920 /// A certificate is verified when presented; a client may present none.
921 Optional,
922}
923
924fn default_http_listen() -> String {
925 "0.0.0.0:8080".into()
926}
927
928impl Default for ListenConfig {
929 fn default() -> Self {
930 Self {
931 http: default_http_listen(),
932 max_connections: None,
933 tls: None,
934 idle_timeout_secs: default_idle_timeout_secs(),
935 header_read_timeout_secs: default_header_read_timeout_secs(),
936 drain_timeout_secs: default_drain_timeout_secs(),
937 }
938 }
939}
940
941/// Service identity.
942#[derive(Debug, Clone, Deserialize)]
943#[non_exhaustive]
944pub struct ServiceConfig {
945 /// Service name (appears in /health response and metrics namespace).
946 #[serde(default = "default_service_name")]
947 pub name: String,
948}
949
950fn default_service_name() -> String {
951 "structured-proxy".into()
952}
953
954impl Default for ServiceConfig {
955 fn default() -> Self {
956 Self {
957 name: default_service_name(),
958 }
959 }
960}
961
962/// Path alias (rewrite before routing).
963#[derive(Debug, Clone, Deserialize)]
964#[non_exhaustive]
965pub struct AliasConfig {
966 pub from: String,
967 pub to: String,
968}
969
970impl AliasConfig {
971 /// Serve the routes under `to` at `from` as well, e.g. `/oauth2/{path}`
972 /// for `/v1/oauth2/{path}`.
973 pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
974 Self {
975 from: from.into(),
976 to: to.into(),
977 }
978 }
979}
980
981/// OpenAPI generation config.
982#[derive(Debug, Clone, Deserialize)]
983#[non_exhaustive]
984pub struct OpenApiConfig {
985 #[serde(default = "default_true")]
986 pub enabled: bool,
987 /// Path for OpenAPI JSON spec (default: "/openapi.json").
988 #[serde(default = "default_openapi_path")]
989 pub path: String,
990 /// Path for interactive API docs UI (default: "/docs").
991 #[serde(default = "default_docs_path")]
992 pub docs_path: String,
993 #[serde(default)]
994 pub title: Option<String>,
995 #[serde(default)]
996 pub version: Option<String>,
997}
998
999fn default_openapi_path() -> String {
1000 "/openapi.json".into()
1001}
1002
1003fn default_docs_path() -> String {
1004 "/docs".into()
1005}
1006
1007fn default_true() -> bool {
1008 true
1009}
1010
1011/// Auth configuration.
1012#[derive(Debug, Clone, Deserialize)]
1013#[non_exhaustive]
1014pub struct AuthConfig {
1015 /// Auth mode: "none", "jwt", "api_key".
1016 #[serde(default = "default_auth_mode")]
1017 pub mode: String,
1018
1019 /// JWT validation config.
1020 #[serde(default)]
1021 pub jwt: Option<JwtConfig>,
1022
1023 /// Forward auth endpoint.
1024 #[serde(default)]
1025 pub forward_auth: Option<ForwardAuthConfig>,
1026
1027 /// AuthZ integration (optional gRPC call).
1028 #[serde(default)]
1029 pub authz: Option<AuthzConfig>,
1030
1031 /// The traffic JWT authentication covers. Default: `transcoded` and
1032 /// `endpoints`; the forward-auth endpoint is never behind it.
1033 #[serde(default)]
1034 pub scope: Option<ScopeConfig>,
1035}
1036
1037/// The traffic a guard covers, and optionally which paths and methods of it.
1038///
1039/// ```yaml
1040/// scope:
1041/// traffic: [transcoded, grpc] # or [all]
1042/// paths: ["/v1/**", "/acme.v1.Orders/*"]
1043/// methods: ["POST"]
1044/// ```
1045///
1046/// Omitted keys keep the guard's own default traffic and cover every path and
1047/// method of it.
1048#[derive(Debug, Clone, Default, Deserialize)]
1049#[serde(deny_unknown_fields)]
1050#[non_exhaustive]
1051pub struct ScopeConfig {
1052 /// The classes of traffic covered; `all` names every class.
1053 #[serde(default)]
1054 pub traffic: Option<Vec<Traffic>>,
1055 /// Path globs (`*` within a segment, `**` across segments) the guard is
1056 /// narrowed to; empty covers every path. A native gRPC call's path is
1057 /// `/<package>.<Service>/<Method>`.
1058 #[serde(default)]
1059 pub paths: Vec<String>,
1060 /// Methods the guard is narrowed to; empty covers every method. Each is a
1061 /// standard method or one a route answers (a `custom` rule, an extra
1062 /// route); a scope covering the fallback takes any method token.
1063 #[serde(default)]
1064 pub methods: Vec<String>,
1065}
1066
1067impl ScopeConfig {
1068 /// A scope covering every path and method of `traffic`.
1069 ///
1070 /// # Examples
1071 ///
1072 /// ```
1073 /// use structured_proxy::config::{ScopeConfig, Traffic};
1074 ///
1075 /// let scope = ScopeConfig::traffic([Traffic::Transcoded, Traffic::Grpc]);
1076 /// assert!(scope.paths.is_empty());
1077 /// ```
1078 pub fn traffic(traffic: impl IntoIterator<Item = Traffic>) -> Self {
1079 Self {
1080 traffic: Some(traffic.into_iter().collect()),
1081 paths: Vec::new(),
1082 methods: Vec::new(),
1083 }
1084 }
1085}
1086
1087/// A class of traffic the proxy tells apart before any guard runs.
1088#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1089#[serde(rename_all = "snake_case")]
1090#[non_exhaustive]
1091pub enum Traffic {
1092 /// REST calls the proxy transcodes to gRPC.
1093 Transcoded,
1094 /// The proxy's own endpoints: health, metrics, OpenAPI, OIDC, extra
1095 /// routes and forward-auth.
1096 Endpoints,
1097 /// Native gRPC and gRPC-Web calls passed through to the upstream.
1098 Grpc,
1099 /// Requests no route answers, handed to the fallback.
1100 Fallback,
1101 /// Every class above.
1102 All,
1103}
1104
1105/// Request concurrency limit: requests past `max_in_flight` are answered
1106/// `UNAVAILABLE` (503) at once instead of queueing.
1107#[derive(Debug, Clone, Deserialize)]
1108#[serde(deny_unknown_fields)]
1109#[non_exhaustive]
1110pub struct ConcurrencyConfig {
1111 /// Most requests in flight at once, counted until each response body
1112 /// ends, so a stream holds its slot for its whole life. At least 1.
1113 pub max_in_flight: usize,
1114 /// The traffic the limit covers, one budget shared by all of it.
1115 /// Default: `transcoded` and `grpc`; the proxy's own endpoints stay out,
1116 /// so health probes answer while the proxy is saturated.
1117 #[serde(default)]
1118 pub scope: Option<ScopeConfig>,
1119}
1120
1121impl ConcurrencyConfig {
1122 /// At most `max_in_flight` requests at once, over the default traffic.
1123 pub fn new(max_in_flight: usize) -> Self {
1124 Self {
1125 max_in_flight,
1126 scope: None,
1127 }
1128 }
1129
1130 /// The same limit over the traffic `scope` names.
1131 #[must_use]
1132 pub fn with_scope(mut self, scope: ScopeConfig) -> Self {
1133 self.scope = Some(scope);
1134 self
1135 }
1136}
1137
1138fn default_auth_mode() -> String {
1139 "none".into()
1140}
1141
1142/// JWT validation config.
1143#[derive(Debug, Clone, Deserialize)]
1144#[non_exhaustive]
1145pub struct JwtConfig {
1146 /// JWKS URI for key discovery.
1147 #[serde(default)]
1148 pub jwks_uri: Option<String>,
1149 /// Age in seconds after which the JWKS keys are fetched again, so a key
1150 /// the provider removed stops verifying tokens. At least 60, the minimum
1151 /// spacing of refreshes. Default: 300.
1152 #[serde(default = "default_jwks_max_age_secs")]
1153 pub jwks_max_age_secs: u64,
1154 /// Expected issuer.
1155 #[serde(default)]
1156 pub issuer: Option<String>,
1157 /// Expected audience.
1158 #[serde(default)]
1159 pub audience: Option<String>,
1160 /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
1161 #[serde(default)]
1162 pub public_key_pem_file: Option<PathBuf>,
1163 /// Claims → HTTP headers mapping.
1164 #[serde(default)]
1165 pub claims_headers: std::collections::HashMap<String, String>,
1166 /// Claim holding the user's roles (array of strings). Supports a dotted
1167 /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
1168 #[serde(default = "default_roles_claim")]
1169 pub roles_claim: String,
1170 /// Cache of verified tokens for the built-in verifier, so a client that
1171 /// sends the same token on every request pays for one signature check.
1172 #[serde(default)]
1173 pub cache: JwtCacheConfig,
1174}
1175
1176/// `auth.jwt.cache`: verified claims are reused until the earlier of the
1177/// token's `exp` and `max_ttl_secs` after verification. Applies to the
1178/// built-in verifier only; an injected `TokenVerifier` is called every time.
1179#[derive(Debug, Clone, Deserialize)]
1180#[serde(deny_unknown_fields)]
1181#[non_exhaustive]
1182pub struct JwtCacheConfig {
1183 /// Cache verified tokens. Default: true.
1184 #[serde(default = "default_true")]
1185 pub enabled: bool,
1186 /// Most tokens kept at once; once full with live entries, a new token is
1187 /// verified but not stored. Default: 10000.
1188 #[serde(default = "default_jwt_cache_max_entries")]
1189 pub max_entries: usize,
1190 /// Longest time a verification is reused, in seconds. With
1191 /// `jwks_max_age_secs` it bounds how long a token keeps passing after its
1192 /// signing key leaves the JWKS. Default: 60.
1193 #[serde(default = "default_jwt_cache_max_ttl_secs")]
1194 pub max_ttl_secs: u64,
1195 /// Longest token cached, in bytes; a longer one is verified on every
1196 /// request. With `max_entries` it bounds the memory the cache holds, since
1197 /// the claims kept are decoded from the token. Default: 4096.
1198 #[serde(default = "default_jwt_cache_max_token_bytes")]
1199 pub max_token_bytes: usize,
1200}
1201
1202/// Default for [`JwtConfig::jwks_max_age_secs`].
1203pub(crate) fn default_jwks_max_age_secs() -> u64 {
1204 300
1205}
1206
1207fn default_jwt_cache_max_entries() -> usize {
1208 10_000
1209}
1210
1211fn default_jwt_cache_max_ttl_secs() -> u64 {
1212 60
1213}
1214
1215fn default_jwt_cache_max_token_bytes() -> usize {
1216 4096
1217}
1218
1219impl Default for JwtCacheConfig {
1220 fn default() -> Self {
1221 Self {
1222 enabled: true,
1223 max_entries: default_jwt_cache_max_entries(),
1224 max_ttl_secs: default_jwt_cache_max_ttl_secs(),
1225 max_token_bytes: default_jwt_cache_max_token_bytes(),
1226 }
1227 }
1228}
1229
1230/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when
1231/// an injected verifier makes the whole `auth.jwt` block optional.
1232pub(crate) fn default_roles_claim() -> String {
1233 "roles".into()
1234}
1235
1236/// Forward auth config.
1237#[derive(Debug, Clone, Deserialize)]
1238#[non_exhaustive]
1239pub struct ForwardAuthConfig {
1240 #[serde(default)]
1241 pub enabled: bool,
1242 #[serde(default = "default_forward_auth_path")]
1243 pub path: String,
1244 /// Route policies.
1245 #[serde(default)]
1246 pub policies: Vec<RoutePolicyConfig>,
1247 /// Login URL for 401 redirects.
1248 #[serde(default)]
1249 pub login_url: Option<String>,
1250 /// Applications YAML file path.
1251 #[serde(default)]
1252 pub applications_path: Option<PathBuf>,
1253}
1254
1255fn default_forward_auth_path() -> String {
1256 "/auth/verify".into()
1257}
1258
1259/// Route policy entry.
1260#[derive(Debug, Clone, Deserialize)]
1261#[non_exhaustive]
1262pub struct RoutePolicyConfig {
1263 pub path: String,
1264 #[serde(default = "default_methods_all")]
1265 pub methods: Vec<String>,
1266 #[serde(default)]
1267 pub require_auth: bool,
1268 #[serde(default)]
1269 pub required_roles: Vec<String>,
1270}
1271
1272fn default_methods_all() -> Vec<String> {
1273 vec!["*".into()]
1274}
1275
1276/// External authorization via the Envoy ext_authz gRPC contract
1277/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
1278/// ext_authz server.
1279#[derive(Debug, Clone, Deserialize)]
1280#[non_exhaustive]
1281pub struct AuthzConfig {
1282 /// Enable external authorization for proxied API requests.
1283 #[serde(default)]
1284 pub enabled: bool,
1285 /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
1286 /// when enabled; defaults to empty so a disabled block can omit it.
1287 #[serde(default)]
1288 pub endpoint: String,
1289 /// Per-request authorization call timeout, in milliseconds.
1290 #[serde(default = "default_authz_timeout_ms")]
1291 pub timeout_ms: u64,
1292 /// When the authz call itself fails (unreachable / timeout), allow the
1293 /// request through instead of denying. Defaults to false (fail closed).
1294 #[serde(default)]
1295 pub failure_mode_allow: bool,
1296 /// The traffic the check covers. Default: `transcoded`.
1297 #[serde(default)]
1298 pub scope: Option<ScopeConfig>,
1299}
1300
1301fn default_authz_timeout_ms() -> u64 {
1302 200
1303}
1304
1305/// Shield (rate limiting) configuration.
1306///
1307/// The proxy runs embedded on each service instance, so every limit decision is
1308/// made locally with a GCRA shaper (zero blocking latency). A shared store, when
1309/// configured via [`sync`](ShieldConfig::sync), is reconciled asynchronously off
1310/// the request path to approximate a fleet-wide limit; the request path never
1311/// blocks on it.
1312#[derive(Debug, Clone, Deserialize)]
1313#[serde(deny_unknown_fields)]
1314#[non_exhaustive]
1315pub struct ShieldConfig {
1316 #[serde(default)]
1317 pub enabled: bool,
1318 /// Named limit tiers referenced by rules and by tier-name resolution (JWT
1319 /// claim / limit service). Map of profile name → `{ rate, burst }`.
1320 #[serde(default)]
1321 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
1322 /// Rate-limit rules, evaluated in order; the first whose pattern matches the
1323 /// request path applies.
1324 #[serde(default)]
1325 pub rules: Vec<RateRuleConfig>,
1326 /// Profile name applied when a matched rule resolves no other limit (JWT and
1327 /// service resolution absent or empty, and the rule sets no explicit
1328 /// profile). Must name an entry in `profiles`.
1329 #[serde(default)]
1330 pub default_profile: Option<String>,
1331 /// Resolve a key's limit from claims in the validated JWT. Presence enables
1332 /// JWT-based resolution (tier name or explicit numbers).
1333 #[serde(default)]
1334 pub jwt_limits: Option<JwtLimitConfig>,
1335 /// Resolve a key's limit from an external service. The lookup is cached and
1336 /// refreshed in the background; the request path never blocks on it.
1337 #[serde(default)]
1338 pub limit_service: Option<LimitServiceConfig>,
1339 /// Asynchronous cross-instance reconciliation via a shared store. When unset,
1340 /// each instance limits locally (fleet limit ≈ N × per-instance).
1341 #[serde(default)]
1342 pub sync: Option<SyncConfig>,
1343 /// The traffic the rules apply to. Default: `transcoded` and `endpoints`.
1344 #[serde(default)]
1345 pub scope: Option<ScopeConfig>,
1346}
1347
1348/// A named limit tier: a sustained rate plus an instantaneous burst capacity.
1349#[derive(Debug, Clone, Deserialize)]
1350#[serde(deny_unknown_fields)]
1351#[non_exhaustive]
1352pub struct LimitProfileConfig {
1353 /// Sustained rate as `"<count>/<unit>"` (e.g. `"100/min"`, units
1354 /// `s`/`min`/`hour`) or a bare count (interpreted per minute).
1355 pub rate: String,
1356 /// Maximum requests admitted back-to-back before throttling to the rate.
1357 /// Defaults to the per-window rate count (one full window of burst).
1358 #[serde(default)]
1359 pub burst: Option<u64>,
1360}
1361
1362/// One rate-limit rule: a path pattern, how to key it, and an optional static
1363/// profile. The rule's *phase* (before or after auth) is derived from its key
1364/// alone: a `jwt_claim` key needs validated claims so it runs after auth; `ip`
1365/// and `header` keys run before auth so anonymous floods are shed before any
1366/// signature verification. (`jwt_limits` therefore only takes effect on
1367/// `jwt_claim` rules, the only ones running with claims available.)
1368#[derive(Debug, Clone, Deserialize)]
1369#[serde(deny_unknown_fields)]
1370#[non_exhaustive]
1371pub struct RateRuleConfig {
1372 /// Glob path pattern (`*` within a segment, `**` across segments).
1373 pub pattern: String,
1374 /// How to derive the limit key (who is limited). Defaults to client IP.
1375 #[serde(default)]
1376 pub key: KeySourceConfig,
1377 /// Static profile name for this rule, used when JWT/service resolution does
1378 /// not apply or yields nothing. Must name an entry in `profiles`.
1379 #[serde(default)]
1380 pub profile: Option<String>,
1381}
1382
1383/// How a rule derives its limit key. All sources fall back to the client IP when
1384/// their value is absent, so a limit can't be bypassed by omitting a header or
1385/// authenticating anonymously. Written as a tagged map, e.g.
1386/// `key: { type: jwt_claim, claim: sub }`; omitting `key` defaults to `ip`.
1387#[derive(Debug, Clone, Default, PartialEq, Eq)]
1388#[non_exhaustive]
1389pub enum KeySourceConfig {
1390 /// The client address the proxy resolved (see
1391 /// [`ClientAddressConfig`]). `{ type: ip }`.
1392 #[default]
1393 Ip,
1394 /// Value of a named request header (e.g. an API key).
1395 /// `{ type: header, name: x-api-key }`.
1396 Header {
1397 /// Header whose value identifies the client.
1398 name: String,
1399 },
1400 /// Value of a claim from the validated JWT (provider-agnostic).
1401 /// `{ type: jwt_claim, claim: sub }`.
1402 JwtClaim {
1403 /// Claim whose value identifies the principal.
1404 claim: String,
1405 },
1406}
1407
1408/// Flat wire form of a rule key. `deny_unknown_fields` rejects any field outside
1409/// this set, and the manual [`KeySourceConfig`] deserializer additionally rejects
1410/// a field that belongs to a *different* variant (e.g. `name` on an `ip` key), so
1411/// a copy-edit leftover can't silently downgrade the intended key source. serde
1412/// does not honour `deny_unknown_fields` on internally-tagged enums directly,
1413/// hence this intermediate.
1414#[derive(Deserialize)]
1415#[serde(deny_unknown_fields, rename_all = "snake_case")]
1416struct KeySourceRaw {
1417 #[serde(rename = "type")]
1418 kind: String,
1419 #[serde(default)]
1420 name: Option<String>,
1421 #[serde(default)]
1422 claim: Option<String>,
1423}
1424
1425impl<'de> Deserialize<'de> for KeySourceConfig {
1426 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1427 where
1428 D: serde::Deserializer<'de>,
1429 {
1430 use serde::de::Error;
1431 let raw = KeySourceRaw::deserialize(deserializer)?;
1432 match raw.kind.as_str() {
1433 "ip" => {
1434 if raw.name.is_some() || raw.claim.is_some() {
1435 return Err(D::Error::custom("key type 'ip' takes no other fields"));
1436 }
1437 Ok(Self::Ip)
1438 }
1439 "header" => {
1440 if raw.claim.is_some() {
1441 return Err(D::Error::custom(
1442 "key type 'header' takes 'name', not 'claim'",
1443 ));
1444 }
1445 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
1446 Ok(Self::Header { name })
1447 }
1448 "jwt_claim" => {
1449 if raw.name.is_some() {
1450 return Err(D::Error::custom(
1451 "key type 'jwt_claim' takes 'claim', not 'name'",
1452 ));
1453 }
1454 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
1455 Ok(Self::JwtClaim { claim })
1456 }
1457 other => Err(D::Error::unknown_variant(
1458 other,
1459 &["ip", "header", "jwt_claim"],
1460 )),
1461 }
1462 }
1463}
1464
1465/// Claims that carry a key's limit inside the JWT itself. A tier-name claim maps
1466/// to a `profiles` entry (numbers stay tunable in config); direct numeric claims
1467/// set the limit explicitly.
1468#[derive(Debug, Clone, Deserialize)]
1469#[serde(deny_unknown_fields)]
1470#[non_exhaustive]
1471pub struct JwtLimitConfig {
1472 /// Claim naming a profile tier (e.g. `"premium"`). Default: `ratelimit_tier`.
1473 #[serde(default = "default_tier_claim")]
1474 pub tier_claim: String,
1475 /// Claim carrying an explicit sustained rate, requests per minute. Default:
1476 /// `ratelimit_rpm`.
1477 #[serde(default = "default_rpm_claim")]
1478 pub rpm_claim: String,
1479 /// Claim carrying an explicit burst capacity. Default: `ratelimit_burst`.
1480 #[serde(default = "default_burst_claim")]
1481 pub burst_claim: String,
1482}
1483
1484fn default_tier_claim() -> String {
1485 "ratelimit_tier".to_string()
1486}
1487fn default_rpm_claim() -> String {
1488 "ratelimit_rpm".to_string()
1489}
1490fn default_burst_claim() -> String {
1491 "ratelimit_burst".to_string()
1492}
1493
1494/// External limit-resolution service. The response names a tier or gives explicit
1495/// numbers; results are cached and refreshed asynchronously, never on the request
1496/// path.
1497#[derive(Debug, Clone, Deserialize)]
1498#[serde(deny_unknown_fields)]
1499#[non_exhaustive]
1500pub struct LimitServiceConfig {
1501 /// HTTP endpoint queried with the limit key; returns `{ tier }` or
1502 /// `{ rate_per_min, burst }`.
1503 pub endpoint: String,
1504 /// How long a resolved limit is cached before a background refresh, in
1505 /// seconds (default: 300).
1506 #[serde(default = "default_limit_ttl_secs")]
1507 pub ttl_secs: u64,
1508 /// Timeout for the background fetch, in milliseconds (default: 500).
1509 #[serde(default = "default_limit_timeout_ms")]
1510 pub timeout_ms: u64,
1511}
1512
1513fn default_limit_ttl_secs() -> u64 {
1514 300
1515}
1516fn default_limit_timeout_ms() -> u64 {
1517 500
1518}
1519
1520/// Asynchronous cross-instance reconciliation via a shared store.
1521#[derive(Debug, Clone, Deserialize)]
1522#[serde(deny_unknown_fields)]
1523#[non_exhaustive]
1524pub struct SyncConfig {
1525 /// Shared-store URL (e.g. `"redis://127.0.0.1/"`). Requires the `redis` build
1526 /// feature; without it the proxy logs a warning and stays local-only.
1527 pub redis_url: String,
1528 /// Background push/pull interval in milliseconds (default: 500). The
1529 /// worst-case fleet overshoot is bounded by `(N-1) × rate × interval`.
1530 #[serde(default = "default_sync_interval_ms")]
1531 pub interval_ms: u64,
1532}
1533
1534fn default_sync_interval_ms() -> u64 {
1535 500
1536}
1537
1538/// OIDC discovery config.
1539#[derive(Debug, Clone, Deserialize)]
1540#[non_exhaustive]
1541pub struct OidcDiscoveryConfig {
1542 #[serde(default)]
1543 pub enabled: bool,
1544 pub issuer: String,
1545 #[serde(default)]
1546 pub authorization_endpoint: Option<String>,
1547 #[serde(default)]
1548 pub token_endpoint: Option<String>,
1549 #[serde(default)]
1550 pub userinfo_endpoint: Option<String>,
1551 #[serde(default)]
1552 pub jwks_uri: Option<String>,
1553 #[serde(default)]
1554 pub signing_key: Option<SigningKeyConfig>,
1555}
1556
1557/// Signing key config for JWKS endpoint.
1558#[derive(Debug, Clone, Deserialize)]
1559#[non_exhaustive]
1560pub struct SigningKeyConfig {
1561 #[serde(default = "default_algorithm")]
1562 pub algorithm: String,
1563 pub public_key_pem_file: PathBuf,
1564}
1565
1566fn default_algorithm() -> String {
1567 "EdDSA".into()
1568}
1569
1570/// Health-probe endpoint configuration.
1571///
1572/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
1573/// `/internal/` prefix) or disable them when a fronting platform supplies its
1574/// own. Defaults match the conventional `/health*` layout.
1575#[derive(Debug, Clone, Deserialize)]
1576#[non_exhaustive]
1577pub struct HealthConfig {
1578 /// Mount the health endpoints. Default: true.
1579 #[serde(default = "default_true")]
1580 pub enabled: bool,
1581 /// Aggregate health endpoint. Default: `/health`.
1582 #[serde(default = "default_health_path")]
1583 pub path: String,
1584 /// Liveness probe. Default: `/health/live`.
1585 #[serde(default = "default_health_live_path")]
1586 pub live_path: String,
1587 /// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
1588 #[serde(default = "default_health_ready_path")]
1589 pub ready_path: String,
1590 /// Startup probe. Default: `/health/startup`.
1591 #[serde(default = "default_health_startup_path")]
1592 pub startup_path: String,
1593}
1594
1595fn default_health_path() -> String {
1596 "/health".into()
1597}
1598fn default_health_live_path() -> String {
1599 "/health/live".into()
1600}
1601fn default_health_ready_path() -> String {
1602 "/health/ready".into()
1603}
1604fn default_health_startup_path() -> String {
1605 "/health/startup".into()
1606}
1607
1608impl Default for HealthConfig {
1609 fn default() -> Self {
1610 Self {
1611 enabled: true,
1612 path: default_health_path(),
1613 live_path: default_health_live_path(),
1614 ready_path: default_health_ready_path(),
1615 startup_path: default_health_startup_path(),
1616 }
1617 }
1618}
1619
1620/// Prometheus metrics endpoint configuration.
1621#[derive(Debug, Clone, Deserialize)]
1622#[non_exhaustive]
1623pub struct MetricsConfig {
1624 /// Mount the metrics endpoint. Default: true.
1625 #[serde(default = "default_true")]
1626 pub enabled: bool,
1627 /// Scrape path. Default: `/metrics`.
1628 #[serde(default = "default_metrics_path")]
1629 pub path: String,
1630}
1631
1632fn default_metrics_path() -> String {
1633 "/metrics".into()
1634}
1635
1636impl Default for MetricsConfig {
1637 fn default() -> Self {
1638 Self {
1639 enabled: true,
1640 path: default_metrics_path(),
1641 }
1642 }
1643}
1644
1645/// Maintenance mode config.
1646#[derive(Debug, Clone, Deserialize)]
1647#[non_exhaustive]
1648pub struct MaintenanceConfig {
1649 #[serde(default)]
1650 pub enabled: bool,
1651 /// Paths exempt from maintenance mode (glob patterns).
1652 #[serde(default = "default_exempt_paths")]
1653 pub exempt_paths: Vec<String>,
1654 #[serde(default = "default_maintenance_message")]
1655 pub message: String,
1656 /// The traffic maintenance mode turns away. Default: `transcoded` and
1657 /// `endpoints`.
1658 #[serde(default)]
1659 pub scope: Option<ScopeConfig>,
1660}
1661
1662fn default_exempt_paths() -> Vec<String> {
1663 vec![
1664 "/health/**".into(),
1665 "/.well-known/**".into(),
1666 "/metrics".into(),
1667 "/auth/verify".into(),
1668 ]
1669}
1670
1671fn default_maintenance_message() -> String {
1672 "Service is under maintenance. Please try again later.".into()
1673}
1674
1675impl Default for MaintenanceConfig {
1676 fn default() -> Self {
1677 Self {
1678 enabled: false,
1679 exempt_paths: default_exempt_paths(),
1680 message: default_maintenance_message(),
1681 scope: None,
1682 }
1683 }
1684}
1685
1686/// CORS configuration.
1687#[derive(Debug, Clone, Deserialize)]
1688#[non_exhaustive]
1689pub struct CorsConfig {
1690 /// Allowed origins. Empty = permissive (dev mode).
1691 #[serde(default)]
1692 pub origins: Vec<String>,
1693 /// Response headers a browser script may read on top of the ones the
1694 /// proxy always exposes (`grpc-status`, `grpc-message`,
1695 /// `grpc-status-details-bin` and the rate-limit headers): typically
1696 /// upstream metadata forwarded as a header, such as `x-request-id`.
1697 #[serde(default)]
1698 pub expose_headers: Vec<String>,
1699 /// How long a browser may cache a preflight answer, in seconds. Unset,
1700 /// the browser's own default applies.
1701 #[serde(default)]
1702 pub max_age_secs: Option<u64>,
1703 /// Apply this policy to gRPC-Web calls that pass through to the upstream
1704 /// and to their preflights, so a browser gets one policy for both (on by
1705 /// default). Off only for an upstream that sets CORS on gRPC-Web itself:
1706 /// its preflights then reach the upstream as well.
1707 #[serde(default = "default_true")]
1708 pub grpc_web: bool,
1709}
1710
1711impl Default for CorsConfig {
1712 fn default() -> Self {
1713 Self {
1714 origins: Vec::new(),
1715 expose_headers: Vec::new(),
1716 max_age_secs: None,
1717 grpc_web: true,
1718 }
1719 }
1720}
1721
1722/// Logging configuration.
1723#[derive(Debug, Clone, Deserialize)]
1724#[non_exhaustive]
1725pub struct LoggingConfig {
1726 #[serde(default = "default_log_level")]
1727 pub level: String,
1728 #[serde(default = "default_log_format")]
1729 pub format: String,
1730}
1731
1732fn default_log_level() -> String {
1733 "info".into()
1734}
1735fn default_log_format() -> String {
1736 "json".into()
1737}
1738
1739impl Default for LoggingConfig {
1740 fn default() -> Self {
1741 Self {
1742 level: default_log_level(),
1743 format: default_log_format(),
1744 }
1745 }
1746}
1747
1748/// Metrics endpoint classification.
1749#[derive(Debug, Clone, Deserialize)]
1750#[non_exhaustive]
1751pub struct MetricsClassConfig {
1752 /// Glob pattern for path matching.
1753 pub pattern: String,
1754 /// Label value for this class.
1755 pub class: String,
1756}
1757
1758impl ProxyConfig {
1759 /// Load configuration from a YAML file.
1760 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1761 Self::from_yaml_str(&std::fs::read_to_string(path)?)
1762 }
1763
1764 /// Parse configuration from a YAML string.
1765 ///
1766 /// Useful for embedding the proxy: load a baked-in config (e.g. via
1767 /// `include_str!`) without touching the filesystem.
1768 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1769 let config: Self = match parse_yaml(yaml) {
1770 Ok(config) => config,
1771 Err(error) => return Err(moved_setting(yaml).map_or_else(|| error.into(), Into::into)),
1772 };
1773 config.validate()?;
1774 Ok(config)
1775 }
1776
1777 /// Validate cross-field constraints that the type system can't express.
1778 ///
1779 /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
1780 /// directly when building a [`ProxyConfig`] programmatically so the same
1781 /// invariants are enforced on the embedded path.
1782 pub fn validate(&self) -> anyhow::Result<()> {
1783 if self.streaming.sse_keep_alive_secs == 0 {
1784 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1785 }
1786 if self.grpc_web.translate && !self.cors.grpc_web {
1787 anyhow::bail!(
1788 "grpc_web.translate needs cors.grpc_web: an upstream that speaks only \
1789 gRPC cannot answer a browser's preflight for a gRPC-Web call"
1790 );
1791 }
1792 crate::client_address::Resolver::build(&self.client_address)
1793 .map_err(|e| anyhow::anyhow!("invalid client_address config: {e}"))?;
1794 self.validate_edge_paths()?;
1795 Ok(())
1796 }
1797
1798 /// Reject malformed or duplicate built-in edge paths up front, so the router
1799 /// does not panic at construction (axum rejects a route that does not start
1800 /// with `/`, and panics on a path registered twice, e.g. setting
1801 /// `health.path` to the default `live_path`).
1802 fn validate_edge_paths(&self) -> anyhow::Result<()> {
1803 let mut seen = std::collections::HashSet::new();
1804 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1805 if !path.starts_with('/') {
1806 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1807 }
1808 if !seen.insert(path.to_string()) {
1809 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1810 }
1811 Ok(())
1812 };
1813 if self.health.enabled {
1814 check("health.path", &self.health.path)?;
1815 check("health.live_path", &self.health.live_path)?;
1816 check("health.ready_path", &self.health.ready_path)?;
1817 check("health.startup_path", &self.health.startup_path)?;
1818 }
1819 if self.metrics.enabled {
1820 check("metrics.path", &self.metrics.path)?;
1821 }
1822 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1823 check("openapi.path", &openapi.path)?;
1824 check("openapi.docs_path", &openapi.docs_path)?;
1825 }
1826 Ok(())
1827 }
1828
1829 /// Parse rate string like "20/min" → requests per window.
1830 pub fn parse_rate(rate: &str) -> Option<u32> {
1831 let parts: Vec<&str> = rate.split('/').collect();
1832 if parts.len() != 2 {
1833 return None;
1834 }
1835 parts[0].trim().parse().ok()
1836 }
1837}
1838
1839#[cfg(test)]
1840mod tests;