Skip to main content

structured_proxy/
config.rs

1//! YAML-based proxy configuration.
2//!
3//! All product-specific behavior is driven by config, not code.
4//! Same binary, different YAML = different product proxy.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// Top-level proxy configuration (loaded from YAML).
10///
11/// 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;