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/// This and the wiring structs below (`UpstreamConfig`, `ListenConfig`,
12/// `ServiceConfig`, `DescriptorSource`) are intentionally NOT
13/// `#[non_exhaustive]`: embedding consumers build them programmatically with
14/// runtime values. The leaf auth/shield/oidc config structs are
15/// `#[non_exhaustive]` instead, since those are deserialized, not hand-built.
16#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18    /// Upstream gRPC service(s).
19    pub upstream: UpstreamConfig,
20
21    /// Proto descriptor sources.
22    #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23    pub descriptors: Vec<DescriptorSource>,
24
25    /// Listen addresses.
26    #[serde(default)]
27    pub listen: ListenConfig,
28
29    /// Service identity (for health endpoint, metrics namespace).
30    #[serde(default)]
31    pub service: ServiceConfig,
32
33    /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
34    #[serde(default)]
35    pub aliases: Vec<AliasConfig>,
36
37    /// OpenAPI generation.
38    #[serde(default)]
39    pub openapi: Option<OpenApiConfig>,
40
41    /// Auth configuration (JWT, forward auth, AuthZ).
42    #[serde(default)]
43    pub auth: Option<AuthConfig>,
44
45    /// Rate limiting (Shield).
46    #[serde(default)]
47    pub shield: Option<ShieldConfig>,
48
49    /// OIDC discovery (optional — for IdP proxies).
50    #[serde(default)]
51    pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53    /// Health-probe endpoints (paths configurable; can be disabled).
54    #[serde(default)]
55    pub health: HealthConfig,
56
57    /// Prometheus metrics endpoint (path configurable; can be disabled).
58    #[serde(default)]
59    pub metrics: MetricsConfig,
60
61    /// Maintenance mode.
62    #[serde(default)]
63    pub maintenance: MaintenanceConfig,
64
65    /// CORS configuration.
66    #[serde(default)]
67    pub cors: CorsConfig,
68
69    /// Logging.
70    #[serde(default)]
71    pub logging: LoggingConfig,
72
73    /// Metrics endpoint classification (path patterns → class labels).
74    #[serde(default)]
75    pub metrics_classes: Vec<MetricsClassConfig>,
76
77    /// Headers to forward from HTTP to gRPC metadata.
78    #[serde(default = "default_forwarded_headers")]
79    pub forwarded_headers: Vec<String>,
80
81    /// Server-streaming response behavior.
82    #[serde(default)]
83    pub streaming: StreamingConfig,
84}
85
86fn default_forwarded_headers() -> Vec<String> {
87    vec![
88        "authorization".into(),
89        "dpop".into(),
90        "x-request-id".into(),
91        "x-forwarded-for".into(),
92        "x-forwarded-proto".into(),
93        "x-real-ip".into(),
94        "accept-language".into(),
95        "user-agent".into(),
96        "idempotency-key".into(),
97    ]
98}
99
100/// Server-streaming response behavior.
101///
102/// Server-streaming RPCs are exposed as NDJSON by default and as Server-Sent
103/// Events when the client sends `Accept: text/event-stream`. The keep-alive
104/// interval applies only to the SSE path.
105#[derive(Debug, Clone, Deserialize)]
106pub struct StreamingConfig {
107    /// SSE keep-alive interval in seconds. Comment frames are emitted on idle
108    /// streams to keep intermediaries (load balancers, nginx) from closing the
109    /// connection on read timeout. Default: 15.
110    #[serde(default = "default_sse_keep_alive_secs")]
111    pub sse_keep_alive_secs: u64,
112}
113
114fn default_sse_keep_alive_secs() -> u64 {
115    15
116}
117
118impl Default for StreamingConfig {
119    fn default() -> Self {
120        Self {
121            sse_keep_alive_secs: default_sse_keep_alive_secs(),
122        }
123    }
124}
125
126/// Transcoding settings read from the same YAML document as [`ProxyConfig`] by
127/// [`ProxyServer::from_yaml_str`](crate::ProxyServer::from_yaml_str) and
128/// [`ProxyServer::from_file`](crate::ProxyServer::from_file):
129///
130/// ```yaml
131/// error_details:
132///   enabled: true
133///   opaque: false
134///   routes:
135///     - pattern: "/v1/admin/**"
136///       enabled: false
137///     - pattern: "/v1/partner/**"
138///       opaque: true
139/// streaming:
140///   ndjson_envelope: true
141/// response_headers:
142///   deny: ["x-debug-trace"]
143/// ```
144///
145/// They live outside [`ProxyConfig`] so that embedders who build it as a
146/// struct literal are not affected.
147#[derive(Debug, Default, Deserialize)]
148pub(crate) struct TranscodeFileConfig {
149    #[serde(default)]
150    error_details: Option<ErrorDetailsFileConfig>,
151    #[serde(default)]
152    streaming: StreamingFileConfig,
153    #[serde(default)]
154    response_headers: Option<ResponseHeadersFileConfig>,
155}
156
157/// `response_headers:`. A typo here would silently let an internal header
158/// reach clients, so unknown keys are rejected.
159#[derive(Debug, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct ResponseHeadersFileConfig {
162    /// Upstream response metadata keys kept off the HTTP response.
163    #[serde(default)]
164    deny: Vec<String>,
165}
166
167/// `error_details:`. A typo here would silently change what clients learn
168/// about server-side failures, so unknown keys are rejected.
169#[derive(Debug, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct ErrorDetailsFileConfig {
172    /// Default for routes no rule matches.
173    #[serde(default = "default_true")]
174    enabled: bool,
175    /// Default for routes no rule matches: whether details of types no
176    /// descriptor describes go to `opaqueDetails` instead of being withheld.
177    #[serde(default)]
178    opaque: bool,
179    /// Per-route overrides; for each switch, the first matching rule that
180    /// sets it wins.
181    #[serde(default)]
182    routes: Vec<ErrorDetailsRouteFileConfig>,
183}
184
185/// A rule sets `enabled`, `opaque` or both; one setting neither is rejected.
186#[derive(Debug, Deserialize)]
187#[serde(deny_unknown_fields)]
188struct ErrorDetailsRouteFileConfig {
189    pattern: String,
190    #[serde(default)]
191    enabled: Option<bool>,
192    #[serde(default)]
193    opaque: Option<bool>,
194}
195
196/// The `streaming:` keys [`StreamingConfig`] does not hold. Both read the same
197/// section, so neither can reject the other's keys; [`unknown_config_keys`]
198/// reports the keys neither reads.
199#[derive(Debug, Default, Deserialize)]
200struct StreamingFileConfig {
201    #[serde(default)]
202    ndjson_envelope: bool,
203}
204
205/// Every top-level key a config file may use: the [`ProxyConfig`] fields plus
206/// the transcoding settings kept outside it.
207pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
208    "upstream",
209    "descriptors",
210    "listen",
211    "service",
212    "aliases",
213    "openapi",
214    "auth",
215    "shield",
216    "oidc_discovery",
217    "health",
218    "metrics",
219    "maintenance",
220    "cors",
221    "logging",
222    "metrics_classes",
223    "forwarded_headers",
224    "streaming",
225    "error_details",
226    "response_headers",
227];
228
229/// Every `streaming:` key: the [`StreamingConfig`] fields plus the ones
230/// [`StreamingFileConfig`] reads.
231pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
232
233/// The keys of `yaml` that no setting reads, typically typos (`error_detail:`
234/// for `error_details:`) that would otherwise leave a default silently in
235/// force. They are reported, not rejected, so a file that loaded before keeps
236/// loading.
237pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
238    let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
239        return Vec::new();
240    };
241    let mut unknown: Vec<String> = map
242        .keys()
243        .filter_map(|key| key.as_str())
244        .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
245        .map(str::to_owned)
246        .collect();
247    // `streaming:` is split between StreamingConfig and StreamingFileConfig,
248    // so neither can deny unknown fields; its keys are checked here instead.
249    if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
250        unknown.extend(
251            streaming
252                .keys()
253                .filter_map(|key| key.as_str())
254                .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
255                .map(|key| format!("streaming.{key}")),
256        );
257    }
258    unknown
259}
260
261impl TranscodeFileConfig {
262    /// Compile into the options the transcoded routes are built with.
263    ///
264    /// # Errors
265    ///
266    /// An `error_details` route pattern that is relative or not a valid glob,
267    /// a route rule that sets neither `enabled` nor `opaque`, or a
268    /// `response_headers.deny` entry that is not a header name.
269    pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
270        use crate::transcode::error::ErrorDetailsPolicy;
271        let mut options = crate::transcode::TranscodeOptions::default()
272            .with_ndjson_envelope(self.streaming.ndjson_envelope);
273        if let Some(cfg) = &self.response_headers {
274            let deny = cfg
275                .deny
276                .iter()
277                .map(|name| {
278                    http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
279                        format!("response_headers.deny entry {name:?} is not a header name")
280                    })
281                })
282                .collect::<Result<Vec<_>, _>>()?;
283            options = options.with_denied_response_headers(deny);
284        }
285        if let Some(cfg) = &self.error_details {
286            let base = if cfg.enabled {
287                ErrorDetailsPolicy::default()
288            } else {
289                ErrorDetailsPolicy::disabled()
290            }
291            .with_opaque_details(cfg.opaque);
292            let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
293                // A rule without a switch would match routes and change
294                // nothing, which is a config mistake, not an intent.
295                if rule.enabled.is_none() && rule.opaque.is_none() {
296                    return Err(format!(
297                        "error details route {:?} sets neither enabled nor opaque",
298                        rule.pattern
299                    ));
300                }
301                policy.rule(&rule.pattern, rule.enabled, rule.opaque)
302            })?;
303            options = options.with_error_details(policy);
304        }
305        Ok(options)
306    }
307}
308
309/// Upstream gRPC service configuration.
310#[derive(Debug, Clone, Deserialize)]
311pub struct UpstreamConfig {
312    /// gRPC upstream address (e.g., "http://localhost:4180").
313    pub default: String,
314}
315
316/// Descriptor loading source.
317#[derive(Debug, Clone)]
318pub enum DescriptorSource {
319    /// Pre-compiled descriptor file.
320    File { file: PathBuf },
321    /// gRPC server reflection (development mode).
322    Reflection { reflection: String },
323    /// Embedded bytes (set programmatically, not from YAML).
324    Embedded { bytes: &'static [u8] },
325}
326
327/// Helper for YAML deserialization (only File and Reflection variants).
328#[derive(Debug, Clone, Deserialize)]
329#[serde(untagged)]
330enum DescriptorSourceYaml {
331    File { file: PathBuf },
332    Reflection { reflection: String },
333}
334
335impl From<DescriptorSourceYaml> for DescriptorSource {
336    fn from(yaml: DescriptorSourceYaml) -> Self {
337        match yaml {
338            DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
339            DescriptorSourceYaml::Reflection { reflection } => {
340                DescriptorSource::Reflection { reflection }
341            }
342        }
343    }
344}
345
346fn deserialize_descriptor_sources<'de, D>(
347    deserializer: D,
348) -> std::result::Result<Vec<DescriptorSource>, D::Error>
349where
350    D: serde::Deserializer<'de>,
351{
352    let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
353    Ok(yaml_sources.into_iter().map(Into::into).collect())
354}
355
356/// Listen address configuration.
357#[derive(Debug, Clone, Deserialize)]
358pub struct ListenConfig {
359    /// HTTP listen address (default: "0.0.0.0:8080").
360    #[serde(default = "default_http_listen")]
361    pub http: String,
362}
363
364fn default_http_listen() -> String {
365    "0.0.0.0:8080".into()
366}
367
368impl Default for ListenConfig {
369    fn default() -> Self {
370        Self {
371            http: default_http_listen(),
372        }
373    }
374}
375
376/// Service identity.
377#[derive(Debug, Clone, Deserialize)]
378pub struct ServiceConfig {
379    /// Service name (appears in /health response and metrics namespace).
380    #[serde(default = "default_service_name")]
381    pub name: String,
382}
383
384fn default_service_name() -> String {
385    "structured-proxy".into()
386}
387
388impl Default for ServiceConfig {
389    fn default() -> Self {
390        Self {
391            name: default_service_name(),
392        }
393    }
394}
395
396/// Path alias (rewrite before routing).
397#[derive(Debug, Clone, Deserialize)]
398#[non_exhaustive]
399pub struct AliasConfig {
400    pub from: String,
401    pub to: String,
402}
403
404/// OpenAPI generation config.
405#[derive(Debug, Clone, Deserialize)]
406#[non_exhaustive]
407pub struct OpenApiConfig {
408    #[serde(default = "default_true")]
409    pub enabled: bool,
410    /// Path for OpenAPI JSON spec (default: "/openapi.json").
411    #[serde(default = "default_openapi_path")]
412    pub path: String,
413    /// Path for interactive API docs UI (default: "/docs").
414    #[serde(default = "default_docs_path")]
415    pub docs_path: String,
416    #[serde(default)]
417    pub title: Option<String>,
418    #[serde(default)]
419    pub version: Option<String>,
420}
421
422fn default_openapi_path() -> String {
423    "/openapi.json".into()
424}
425
426fn default_docs_path() -> String {
427    "/docs".into()
428}
429
430fn default_true() -> bool {
431    true
432}
433
434/// Auth configuration.
435#[derive(Debug, Clone, Deserialize)]
436#[non_exhaustive]
437pub struct AuthConfig {
438    /// Auth mode: "none", "jwt", "api_key".
439    #[serde(default = "default_auth_mode")]
440    pub mode: String,
441
442    /// JWT validation config.
443    #[serde(default)]
444    pub jwt: Option<JwtConfig>,
445
446    /// Forward auth endpoint.
447    #[serde(default)]
448    pub forward_auth: Option<ForwardAuthConfig>,
449
450    /// AuthZ integration (optional gRPC call).
451    #[serde(default)]
452    pub authz: Option<AuthzConfig>,
453}
454
455fn default_auth_mode() -> String {
456    "none".into()
457}
458
459/// JWT validation config.
460#[derive(Debug, Clone, Deserialize)]
461#[non_exhaustive]
462pub struct JwtConfig {
463    /// JWKS URI for key discovery.
464    #[serde(default)]
465    pub jwks_uri: Option<String>,
466    /// Expected issuer.
467    #[serde(default)]
468    pub issuer: Option<String>,
469    /// Expected audience.
470    #[serde(default)]
471    pub audience: Option<String>,
472    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
473    #[serde(default)]
474    pub public_key_pem_file: Option<PathBuf>,
475    /// Claims → HTTP headers mapping.
476    #[serde(default)]
477    pub claims_headers: std::collections::HashMap<String, String>,
478    /// Claim holding the user's roles (array of strings). Supports a dotted
479    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
480    #[serde(default = "default_roles_claim")]
481    pub roles_claim: String,
482}
483
484/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when
485/// an injected verifier makes the whole `auth.jwt` block optional.
486pub(crate) fn default_roles_claim() -> String {
487    "roles".into()
488}
489
490/// Forward auth config.
491#[derive(Debug, Clone, Deserialize)]
492#[non_exhaustive]
493pub struct ForwardAuthConfig {
494    #[serde(default)]
495    pub enabled: bool,
496    #[serde(default = "default_forward_auth_path")]
497    pub path: String,
498    /// Route policies.
499    #[serde(default)]
500    pub policies: Vec<RoutePolicyConfig>,
501    /// Login URL for 401 redirects.
502    #[serde(default)]
503    pub login_url: Option<String>,
504    /// Applications YAML file path.
505    #[serde(default)]
506    pub applications_path: Option<PathBuf>,
507}
508
509fn default_forward_auth_path() -> String {
510    "/auth/verify".into()
511}
512
513/// Route policy entry.
514#[derive(Debug, Clone, Deserialize)]
515#[non_exhaustive]
516pub struct RoutePolicyConfig {
517    pub path: String,
518    #[serde(default = "default_methods_all")]
519    pub methods: Vec<String>,
520    #[serde(default)]
521    pub require_auth: bool,
522    #[serde(default)]
523    pub required_roles: Vec<String>,
524}
525
526fn default_methods_all() -> Vec<String> {
527    vec!["*".into()]
528}
529
530/// External authorization via the Envoy ext_authz gRPC contract
531/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
532/// ext_authz server.
533#[derive(Debug, Clone, Deserialize)]
534#[non_exhaustive]
535pub struct AuthzConfig {
536    /// Enable external authorization for proxied API requests.
537    #[serde(default)]
538    pub enabled: bool,
539    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
540    /// when enabled; defaults to empty so a disabled block can omit it.
541    #[serde(default)]
542    pub endpoint: String,
543    /// Per-request authorization call timeout, in milliseconds.
544    #[serde(default = "default_authz_timeout_ms")]
545    pub timeout_ms: u64,
546    /// When the authz call itself fails (unreachable / timeout), allow the
547    /// request through instead of denying. Defaults to false (fail closed).
548    #[serde(default)]
549    pub failure_mode_allow: bool,
550}
551
552fn default_authz_timeout_ms() -> u64 {
553    200
554}
555
556/// Shield (rate limiting) configuration.
557///
558/// The proxy runs embedded on each service instance, so every limit decision is
559/// made locally with a GCRA shaper (zero blocking latency). A shared store, when
560/// configured via [`sync`](ShieldConfig::sync), is reconciled asynchronously off
561/// the request path to approximate a fleet-wide limit; the request path never
562/// blocks on it.
563#[derive(Debug, Clone, Deserialize)]
564#[serde(deny_unknown_fields)]
565#[non_exhaustive]
566pub struct ShieldConfig {
567    #[serde(default)]
568    pub enabled: bool,
569    /// Named limit tiers referenced by rules and by tier-name resolution (JWT
570    /// claim / limit service). Map of profile name → `{ rate, burst }`.
571    #[serde(default)]
572    pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
573    /// Rate-limit rules, evaluated in order; the first whose pattern matches the
574    /// request path applies.
575    #[serde(default)]
576    pub rules: Vec<RateRuleConfig>,
577    /// Profile name applied when a matched rule resolves no other limit (JWT and
578    /// service resolution absent or empty, and the rule sets no explicit
579    /// profile). Must name an entry in `profiles`.
580    #[serde(default)]
581    pub default_profile: Option<String>,
582    /// Resolve a key's limit from claims in the validated JWT. Presence enables
583    /// JWT-based resolution (tier name or explicit numbers).
584    #[serde(default)]
585    pub jwt_limits: Option<JwtLimitConfig>,
586    /// Resolve a key's limit from an external service. The lookup is cached and
587    /// refreshed in the background; the request path never blocks on it.
588    #[serde(default)]
589    pub limit_service: Option<LimitServiceConfig>,
590    /// Asynchronous cross-instance reconciliation via a shared store. When unset,
591    /// each instance limits locally (fleet limit ≈ N × per-instance).
592    #[serde(default)]
593    pub sync: Option<SyncConfig>,
594    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
595    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
596    /// direct peer falls in one of these ranges; otherwise the peer socket
597    /// address is used as the client identity. Empty (the default) means do not
598    /// trust forwarding headers; set this behind a load balancer.
599    #[serde(default)]
600    pub trusted_proxies: Vec<String>,
601}
602
603/// A named limit tier: a sustained rate plus an instantaneous burst capacity.
604#[derive(Debug, Clone, Deserialize)]
605#[serde(deny_unknown_fields)]
606#[non_exhaustive]
607pub struct LimitProfileConfig {
608    /// Sustained rate as `"<count>/<unit>"` (e.g. `"100/min"`, units
609    /// `s`/`min`/`hour`) or a bare count (interpreted per minute).
610    pub rate: String,
611    /// Maximum requests admitted back-to-back before throttling to the rate.
612    /// Defaults to the per-window rate count (one full window of burst).
613    #[serde(default)]
614    pub burst: Option<u64>,
615}
616
617/// One rate-limit rule: a path pattern, how to key it, and an optional static
618/// profile. The rule's *phase* (before or after auth) is derived from its key
619/// alone: a `jwt_claim` key needs validated claims so it runs after auth; `ip`
620/// and `header` keys run before auth so anonymous floods are shed before any
621/// signature verification. (`jwt_limits` therefore only takes effect on
622/// `jwt_claim` rules, the only ones running with claims available.)
623#[derive(Debug, Clone, Deserialize)]
624#[serde(deny_unknown_fields)]
625#[non_exhaustive]
626pub struct RateRuleConfig {
627    /// Glob path pattern (`*` within a segment, `**` across segments).
628    pub pattern: String,
629    /// How to derive the limit key (who is limited). Defaults to client IP.
630    #[serde(default)]
631    pub key: KeySourceConfig,
632    /// Static profile name for this rule, used when JWT/service resolution does
633    /// not apply or yields nothing. Must name an entry in `profiles`.
634    #[serde(default)]
635    pub profile: Option<String>,
636}
637
638/// How a rule derives its limit key. All sources fall back to the client IP when
639/// their value is absent, so a limit can't be bypassed by omitting a header or
640/// authenticating anonymously. Written as a tagged map, e.g.
641/// `key: { type: jwt_claim, claim: sub }`; omitting `key` defaults to `ip`.
642#[derive(Debug, Clone, Default, PartialEq, Eq)]
643#[non_exhaustive]
644pub enum KeySourceConfig {
645    /// Client IP (trusted-proxy `X-Forwarded-For` aware). `{ type: ip }`.
646    #[default]
647    Ip,
648    /// Value of a named request header (e.g. an API key).
649    /// `{ type: header, name: x-api-key }`.
650    Header {
651        /// Header whose value identifies the client.
652        name: String,
653    },
654    /// Value of a claim from the validated JWT (provider-agnostic).
655    /// `{ type: jwt_claim, claim: sub }`.
656    JwtClaim {
657        /// Claim whose value identifies the principal.
658        claim: String,
659    },
660}
661
662/// Flat wire form of a rule key. `deny_unknown_fields` rejects any field outside
663/// this set, and the manual [`KeySourceConfig`] deserializer additionally rejects
664/// a field that belongs to a *different* variant (e.g. `name` on an `ip` key), so
665/// a copy-edit leftover can't silently downgrade the intended key source. serde
666/// does not honour `deny_unknown_fields` on internally-tagged enums directly,
667/// hence this intermediate.
668#[derive(Deserialize)]
669#[serde(deny_unknown_fields, rename_all = "snake_case")]
670struct KeySourceRaw {
671    #[serde(rename = "type")]
672    kind: String,
673    #[serde(default)]
674    name: Option<String>,
675    #[serde(default)]
676    claim: Option<String>,
677}
678
679impl<'de> Deserialize<'de> for KeySourceConfig {
680    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
681    where
682        D: serde::Deserializer<'de>,
683    {
684        use serde::de::Error;
685        let raw = KeySourceRaw::deserialize(deserializer)?;
686        match raw.kind.as_str() {
687            "ip" => {
688                if raw.name.is_some() || raw.claim.is_some() {
689                    return Err(D::Error::custom("key type 'ip' takes no other fields"));
690                }
691                Ok(Self::Ip)
692            }
693            "header" => {
694                if raw.claim.is_some() {
695                    return Err(D::Error::custom(
696                        "key type 'header' takes 'name', not 'claim'",
697                    ));
698                }
699                let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
700                Ok(Self::Header { name })
701            }
702            "jwt_claim" => {
703                if raw.name.is_some() {
704                    return Err(D::Error::custom(
705                        "key type 'jwt_claim' takes 'claim', not 'name'",
706                    ));
707                }
708                let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
709                Ok(Self::JwtClaim { claim })
710            }
711            other => Err(D::Error::unknown_variant(
712                other,
713                &["ip", "header", "jwt_claim"],
714            )),
715        }
716    }
717}
718
719/// Claims that carry a key's limit inside the JWT itself. A tier-name claim maps
720/// to a `profiles` entry (numbers stay tunable in config); direct numeric claims
721/// set the limit explicitly.
722#[derive(Debug, Clone, Deserialize)]
723#[serde(deny_unknown_fields)]
724#[non_exhaustive]
725pub struct JwtLimitConfig {
726    /// Claim naming a profile tier (e.g. `"premium"`). Default: `ratelimit_tier`.
727    #[serde(default = "default_tier_claim")]
728    pub tier_claim: String,
729    /// Claim carrying an explicit sustained rate, requests per minute. Default:
730    /// `ratelimit_rpm`.
731    #[serde(default = "default_rpm_claim")]
732    pub rpm_claim: String,
733    /// Claim carrying an explicit burst capacity. Default: `ratelimit_burst`.
734    #[serde(default = "default_burst_claim")]
735    pub burst_claim: String,
736}
737
738fn default_tier_claim() -> String {
739    "ratelimit_tier".to_string()
740}
741fn default_rpm_claim() -> String {
742    "ratelimit_rpm".to_string()
743}
744fn default_burst_claim() -> String {
745    "ratelimit_burst".to_string()
746}
747
748/// External limit-resolution service. The response names a tier or gives explicit
749/// numbers; results are cached and refreshed asynchronously, never on the request
750/// path.
751#[derive(Debug, Clone, Deserialize)]
752#[serde(deny_unknown_fields)]
753#[non_exhaustive]
754pub struct LimitServiceConfig {
755    /// HTTP endpoint queried with the limit key; returns `{ tier }` or
756    /// `{ rate_per_min, burst }`.
757    pub endpoint: String,
758    /// How long a resolved limit is cached before a background refresh, in
759    /// seconds (default: 300).
760    #[serde(default = "default_limit_ttl_secs")]
761    pub ttl_secs: u64,
762    /// Timeout for the background fetch, in milliseconds (default: 500).
763    #[serde(default = "default_limit_timeout_ms")]
764    pub timeout_ms: u64,
765}
766
767fn default_limit_ttl_secs() -> u64 {
768    300
769}
770fn default_limit_timeout_ms() -> u64 {
771    500
772}
773
774/// Asynchronous cross-instance reconciliation via a shared store.
775#[derive(Debug, Clone, Deserialize)]
776#[serde(deny_unknown_fields)]
777#[non_exhaustive]
778pub struct SyncConfig {
779    /// Shared-store URL (e.g. `"redis://127.0.0.1/"`). Requires the `redis` build
780    /// feature; without it the proxy logs a warning and stays local-only.
781    pub redis_url: String,
782    /// Background push/pull interval in milliseconds (default: 500). The
783    /// worst-case fleet overshoot is bounded by `(N-1) × rate × interval`.
784    #[serde(default = "default_sync_interval_ms")]
785    pub interval_ms: u64,
786}
787
788fn default_sync_interval_ms() -> u64 {
789    500
790}
791
792/// OIDC discovery config.
793#[derive(Debug, Clone, Deserialize)]
794#[non_exhaustive]
795pub struct OidcDiscoveryConfig {
796    #[serde(default)]
797    pub enabled: bool,
798    pub issuer: String,
799    #[serde(default)]
800    pub authorization_endpoint: Option<String>,
801    #[serde(default)]
802    pub token_endpoint: Option<String>,
803    #[serde(default)]
804    pub userinfo_endpoint: Option<String>,
805    #[serde(default)]
806    pub jwks_uri: Option<String>,
807    #[serde(default)]
808    pub signing_key: Option<SigningKeyConfig>,
809}
810
811/// Signing key config for JWKS endpoint.
812#[derive(Debug, Clone, Deserialize)]
813#[non_exhaustive]
814pub struct SigningKeyConfig {
815    #[serde(default = "default_algorithm")]
816    pub algorithm: String,
817    pub public_key_pem_file: PathBuf,
818}
819
820fn default_algorithm() -> String {
821    "EdDSA".into()
822}
823
824/// Health-probe endpoint configuration.
825///
826/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
827/// `/internal/` prefix) or disable them when a fronting platform supplies its
828/// own. Defaults match the conventional `/health*` layout.
829#[derive(Debug, Clone, Deserialize)]
830#[non_exhaustive]
831pub struct HealthConfig {
832    /// Mount the health endpoints. Default: true.
833    #[serde(default = "default_true")]
834    pub enabled: bool,
835    /// Aggregate health endpoint. Default: `/health`.
836    #[serde(default = "default_health_path")]
837    pub path: String,
838    /// Liveness probe. Default: `/health/live`.
839    #[serde(default = "default_health_live_path")]
840    pub live_path: String,
841    /// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
842    #[serde(default = "default_health_ready_path")]
843    pub ready_path: String,
844    /// Startup probe. Default: `/health/startup`.
845    #[serde(default = "default_health_startup_path")]
846    pub startup_path: String,
847}
848
849fn default_health_path() -> String {
850    "/health".into()
851}
852fn default_health_live_path() -> String {
853    "/health/live".into()
854}
855fn default_health_ready_path() -> String {
856    "/health/ready".into()
857}
858fn default_health_startup_path() -> String {
859    "/health/startup".into()
860}
861
862impl Default for HealthConfig {
863    fn default() -> Self {
864        Self {
865            enabled: true,
866            path: default_health_path(),
867            live_path: default_health_live_path(),
868            ready_path: default_health_ready_path(),
869            startup_path: default_health_startup_path(),
870        }
871    }
872}
873
874/// Prometheus metrics endpoint configuration.
875#[derive(Debug, Clone, Deserialize)]
876#[non_exhaustive]
877pub struct MetricsConfig {
878    /// Mount the metrics endpoint. Default: true.
879    #[serde(default = "default_true")]
880    pub enabled: bool,
881    /// Scrape path. Default: `/metrics`.
882    #[serde(default = "default_metrics_path")]
883    pub path: String,
884}
885
886fn default_metrics_path() -> String {
887    "/metrics".into()
888}
889
890impl Default for MetricsConfig {
891    fn default() -> Self {
892        Self {
893            enabled: true,
894            path: default_metrics_path(),
895        }
896    }
897}
898
899/// Maintenance mode config.
900#[derive(Debug, Clone, Deserialize)]
901#[non_exhaustive]
902pub struct MaintenanceConfig {
903    #[serde(default)]
904    pub enabled: bool,
905    /// Paths exempt from maintenance mode (glob patterns).
906    #[serde(default = "default_exempt_paths")]
907    pub exempt_paths: Vec<String>,
908    #[serde(default = "default_maintenance_message")]
909    pub message: String,
910}
911
912fn default_exempt_paths() -> Vec<String> {
913    vec![
914        "/health/**".into(),
915        "/.well-known/**".into(),
916        "/metrics".into(),
917        "/auth/verify".into(),
918    ]
919}
920
921fn default_maintenance_message() -> String {
922    "Service is under maintenance. Please try again later.".into()
923}
924
925impl Default for MaintenanceConfig {
926    fn default() -> Self {
927        Self {
928            enabled: false,
929            exempt_paths: default_exempt_paths(),
930            message: default_maintenance_message(),
931        }
932    }
933}
934
935/// CORS configuration.
936#[derive(Debug, Clone, Default, Deserialize)]
937#[non_exhaustive]
938pub struct CorsConfig {
939    /// Allowed origins. Empty = permissive (dev mode).
940    #[serde(default)]
941    pub origins: Vec<String>,
942}
943
944/// Logging configuration.
945#[derive(Debug, Clone, Deserialize)]
946#[non_exhaustive]
947pub struct LoggingConfig {
948    #[serde(default = "default_log_level")]
949    pub level: String,
950    #[serde(default = "default_log_format")]
951    pub format: String,
952}
953
954fn default_log_level() -> String {
955    "info".into()
956}
957fn default_log_format() -> String {
958    "json".into()
959}
960
961impl Default for LoggingConfig {
962    fn default() -> Self {
963        Self {
964            level: default_log_level(),
965            format: default_log_format(),
966        }
967    }
968}
969
970/// Metrics endpoint classification.
971#[derive(Debug, Clone, Deserialize)]
972#[non_exhaustive]
973pub struct MetricsClassConfig {
974    /// Glob pattern for path matching.
975    pub pattern: String,
976    /// Label value for this class.
977    pub class: String,
978}
979
980impl ProxyConfig {
981    /// Load configuration from a YAML file.
982    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
983        Self::from_yaml_str(&std::fs::read_to_string(path)?)
984    }
985
986    /// Parse configuration from a YAML string.
987    ///
988    /// Useful for embedding the proxy: load a baked-in config (e.g. via
989    /// `include_str!`) without touching the filesystem.
990    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
991        let config: Self = serde_yaml::from_str(yaml)?;
992        config.validate()?;
993        Ok(config)
994    }
995
996    /// Validate cross-field constraints that the type system can't express.
997    ///
998    /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
999    /// directly when building a [`ProxyConfig`] programmatically so the same
1000    /// invariants are enforced on the embedded path.
1001    pub fn validate(&self) -> anyhow::Result<()> {
1002        if self.streaming.sse_keep_alive_secs == 0 {
1003            anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1004        }
1005        self.validate_edge_paths()?;
1006        Ok(())
1007    }
1008
1009    /// Reject malformed or duplicate built-in edge paths up front, so the router
1010    /// does not panic at construction (axum rejects a route that does not start
1011    /// with `/`, and panics on a path registered twice, e.g. setting
1012    /// `health.path` to the default `live_path`).
1013    fn validate_edge_paths(&self) -> anyhow::Result<()> {
1014        let mut seen = std::collections::HashSet::new();
1015        let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1016            if !path.starts_with('/') {
1017                anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1018            }
1019            if !seen.insert(path.to_string()) {
1020                anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1021            }
1022            Ok(())
1023        };
1024        if self.health.enabled {
1025            check("health.path", &self.health.path)?;
1026            check("health.live_path", &self.health.live_path)?;
1027            check("health.ready_path", &self.health.ready_path)?;
1028            check("health.startup_path", &self.health.startup_path)?;
1029        }
1030        if self.metrics.enabled {
1031            check("metrics.path", &self.metrics.path)?;
1032        }
1033        if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1034            check("openapi.path", &openapi.path)?;
1035            check("openapi.docs_path", &openapi.docs_path)?;
1036        }
1037        Ok(())
1038    }
1039
1040    /// Parse rate string like "20/min" → requests per window.
1041    pub fn parse_rate(rate: &str) -> Option<u32> {
1042        let parts: Vec<&str> = rate.split('/').collect();
1043        if parts.len() != 2 {
1044            return None;
1045        }
1046        parts[0].trim().parse().ok()
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests;