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