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