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