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}
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;