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