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    /// Age in seconds after which the JWKS keys are fetched again, so a key
467    /// the provider removed stops verifying tokens. At least 60, the minimum
468    /// spacing of refreshes. Default: 300.
469    #[serde(default = "default_jwks_max_age_secs")]
470    pub jwks_max_age_secs: u64,
471    /// Expected issuer.
472    #[serde(default)]
473    pub issuer: Option<String>,
474    /// Expected audience.
475    #[serde(default)]
476    pub audience: Option<String>,
477    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
478    #[serde(default)]
479    pub public_key_pem_file: Option<PathBuf>,
480    /// Claims → HTTP headers mapping.
481    #[serde(default)]
482    pub claims_headers: std::collections::HashMap<String, String>,
483    /// Claim holding the user's roles (array of strings). Supports a dotted
484    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
485    #[serde(default = "default_roles_claim")]
486    pub roles_claim: String,
487    /// Cache of verified tokens for the built-in verifier, so a client that
488    /// sends the same token on every request pays for one signature check.
489    #[serde(default)]
490    pub cache: JwtCacheConfig,
491}
492
493/// `auth.jwt.cache`: verified claims are reused until the earlier of the
494/// token's `exp` and `max_ttl_secs` after verification. Applies to the
495/// built-in verifier only; an injected `TokenVerifier` is called every time.
496#[derive(Debug, Clone, Deserialize)]
497#[serde(deny_unknown_fields)]
498#[non_exhaustive]
499pub struct JwtCacheConfig {
500    /// Cache verified tokens. Default: true.
501    #[serde(default = "default_true")]
502    pub enabled: bool,
503    /// Most tokens kept at once; once full with live entries, a new token is
504    /// verified but not stored. Default: 10000.
505    #[serde(default = "default_jwt_cache_max_entries")]
506    pub max_entries: usize,
507    /// Longest time a verification is reused, in seconds. With
508    /// `jwks_max_age_secs` it bounds how long a token keeps passing after its
509    /// signing key leaves the JWKS. Default: 60.
510    #[serde(default = "default_jwt_cache_max_ttl_secs")]
511    pub max_ttl_secs: u64,
512    /// Longest token cached, in bytes; a longer one is verified on every
513    /// request. With `max_entries` it bounds the memory the cache holds, since
514    /// the claims kept are decoded from the token. Default: 4096.
515    #[serde(default = "default_jwt_cache_max_token_bytes")]
516    pub max_token_bytes: usize,
517}
518
519/// Default for [`JwtConfig::jwks_max_age_secs`].
520pub(crate) fn default_jwks_max_age_secs() -> u64 {
521    300
522}
523
524fn default_jwt_cache_max_entries() -> usize {
525    10_000
526}
527
528fn default_jwt_cache_max_ttl_secs() -> u64 {
529    60
530}
531
532fn default_jwt_cache_max_token_bytes() -> usize {
533    4096
534}
535
536impl Default for JwtCacheConfig {
537    fn default() -> Self {
538        Self {
539            enabled: true,
540            max_entries: default_jwt_cache_max_entries(),
541            max_ttl_secs: default_jwt_cache_max_ttl_secs(),
542            max_token_bytes: default_jwt_cache_max_token_bytes(),
543        }
544    }
545}
546
547/// Default for [`JwtConfig::roles_claim`]. Also applied by the auth builder when
548/// an injected verifier makes the whole `auth.jwt` block optional.
549pub(crate) fn default_roles_claim() -> String {
550    "roles".into()
551}
552
553/// Forward auth config.
554#[derive(Debug, Clone, Deserialize)]
555#[non_exhaustive]
556pub struct ForwardAuthConfig {
557    #[serde(default)]
558    pub enabled: bool,
559    #[serde(default = "default_forward_auth_path")]
560    pub path: String,
561    /// Route policies.
562    #[serde(default)]
563    pub policies: Vec<RoutePolicyConfig>,
564    /// Login URL for 401 redirects.
565    #[serde(default)]
566    pub login_url: Option<String>,
567    /// Applications YAML file path.
568    #[serde(default)]
569    pub applications_path: Option<PathBuf>,
570}
571
572fn default_forward_auth_path() -> String {
573    "/auth/verify".into()
574}
575
576/// Route policy entry.
577#[derive(Debug, Clone, Deserialize)]
578#[non_exhaustive]
579pub struct RoutePolicyConfig {
580    pub path: String,
581    #[serde(default = "default_methods_all")]
582    pub methods: Vec<String>,
583    #[serde(default)]
584    pub require_auth: bool,
585    #[serde(default)]
586    pub required_roles: Vec<String>,
587}
588
589fn default_methods_all() -> Vec<String> {
590    vec!["*".into()]
591}
592
593/// External authorization via the Envoy ext_authz gRPC contract
594/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
595/// ext_authz server.
596#[derive(Debug, Clone, Deserialize)]
597#[non_exhaustive]
598pub struct AuthzConfig {
599    /// Enable external authorization for proxied API requests.
600    #[serde(default)]
601    pub enabled: bool,
602    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
603    /// when enabled; defaults to empty so a disabled block can omit it.
604    #[serde(default)]
605    pub endpoint: String,
606    /// Per-request authorization call timeout, in milliseconds.
607    #[serde(default = "default_authz_timeout_ms")]
608    pub timeout_ms: u64,
609    /// When the authz call itself fails (unreachable / timeout), allow the
610    /// request through instead of denying. Defaults to false (fail closed).
611    #[serde(default)]
612    pub failure_mode_allow: bool,
613}
614
615fn default_authz_timeout_ms() -> u64 {
616    200
617}
618
619/// Shield (rate limiting) configuration.
620///
621/// The proxy runs embedded on each service instance, so every limit decision is
622/// made locally with a GCRA shaper (zero blocking latency). A shared store, when
623/// configured via [`sync`](ShieldConfig::sync), is reconciled asynchronously off
624/// the request path to approximate a fleet-wide limit; the request path never
625/// blocks on it.
626#[derive(Debug, Clone, Deserialize)]
627#[serde(deny_unknown_fields)]
628#[non_exhaustive]
629pub struct ShieldConfig {
630    #[serde(default)]
631    pub enabled: bool,
632    /// Named limit tiers referenced by rules and by tier-name resolution (JWT
633    /// claim / limit service). Map of profile name → `{ rate, burst }`.
634    #[serde(default)]
635    pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
636    /// Rate-limit rules, evaluated in order; the first whose pattern matches the
637    /// request path applies.
638    #[serde(default)]
639    pub rules: Vec<RateRuleConfig>,
640    /// Profile name applied when a matched rule resolves no other limit (JWT and
641    /// service resolution absent or empty, and the rule sets no explicit
642    /// profile). Must name an entry in `profiles`.
643    #[serde(default)]
644    pub default_profile: Option<String>,
645    /// Resolve a key's limit from claims in the validated JWT. Presence enables
646    /// JWT-based resolution (tier name or explicit numbers).
647    #[serde(default)]
648    pub jwt_limits: Option<JwtLimitConfig>,
649    /// Resolve a key's limit from an external service. The lookup is cached and
650    /// refreshed in the background; the request path never blocks on it.
651    #[serde(default)]
652    pub limit_service: Option<LimitServiceConfig>,
653    /// Asynchronous cross-instance reconciliation via a shared store. When unset,
654    /// each instance limits locally (fleet limit ≈ N × per-instance).
655    #[serde(default)]
656    pub sync: Option<SyncConfig>,
657    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
658    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
659    /// direct peer falls in one of these ranges; otherwise the peer socket
660    /// address is used as the client identity. Empty (the default) means do not
661    /// trust forwarding headers; set this behind a load balancer.
662    #[serde(default)]
663    pub trusted_proxies: Vec<String>,
664}
665
666/// A named limit tier: a sustained rate plus an instantaneous burst capacity.
667#[derive(Debug, Clone, Deserialize)]
668#[serde(deny_unknown_fields)]
669#[non_exhaustive]
670pub struct LimitProfileConfig {
671    /// Sustained rate as `"<count>/<unit>"` (e.g. `"100/min"`, units
672    /// `s`/`min`/`hour`) or a bare count (interpreted per minute).
673    pub rate: String,
674    /// Maximum requests admitted back-to-back before throttling to the rate.
675    /// Defaults to the per-window rate count (one full window of burst).
676    #[serde(default)]
677    pub burst: Option<u64>,
678}
679
680/// One rate-limit rule: a path pattern, how to key it, and an optional static
681/// profile. The rule's *phase* (before or after auth) is derived from its key
682/// alone: a `jwt_claim` key needs validated claims so it runs after auth; `ip`
683/// and `header` keys run before auth so anonymous floods are shed before any
684/// signature verification. (`jwt_limits` therefore only takes effect on
685/// `jwt_claim` rules, the only ones running with claims available.)
686#[derive(Debug, Clone, Deserialize)]
687#[serde(deny_unknown_fields)]
688#[non_exhaustive]
689pub struct RateRuleConfig {
690    /// Glob path pattern (`*` within a segment, `**` across segments).
691    pub pattern: String,
692    /// How to derive the limit key (who is limited). Defaults to client IP.
693    #[serde(default)]
694    pub key: KeySourceConfig,
695    /// Static profile name for this rule, used when JWT/service resolution does
696    /// not apply or yields nothing. Must name an entry in `profiles`.
697    #[serde(default)]
698    pub profile: Option<String>,
699}
700
701/// How a rule derives its limit key. All sources fall back to the client IP when
702/// their value is absent, so a limit can't be bypassed by omitting a header or
703/// authenticating anonymously. Written as a tagged map, e.g.
704/// `key: { type: jwt_claim, claim: sub }`; omitting `key` defaults to `ip`.
705#[derive(Debug, Clone, Default, PartialEq, Eq)]
706#[non_exhaustive]
707pub enum KeySourceConfig {
708    /// Client IP (trusted-proxy `X-Forwarded-For` aware). `{ type: ip }`.
709    #[default]
710    Ip,
711    /// Value of a named request header (e.g. an API key).
712    /// `{ type: header, name: x-api-key }`.
713    Header {
714        /// Header whose value identifies the client.
715        name: String,
716    },
717    /// Value of a claim from the validated JWT (provider-agnostic).
718    /// `{ type: jwt_claim, claim: sub }`.
719    JwtClaim {
720        /// Claim whose value identifies the principal.
721        claim: String,
722    },
723}
724
725/// Flat wire form of a rule key. `deny_unknown_fields` rejects any field outside
726/// this set, and the manual [`KeySourceConfig`] deserializer additionally rejects
727/// a field that belongs to a *different* variant (e.g. `name` on an `ip` key), so
728/// a copy-edit leftover can't silently downgrade the intended key source. serde
729/// does not honour `deny_unknown_fields` on internally-tagged enums directly,
730/// hence this intermediate.
731#[derive(Deserialize)]
732#[serde(deny_unknown_fields, rename_all = "snake_case")]
733struct KeySourceRaw {
734    #[serde(rename = "type")]
735    kind: String,
736    #[serde(default)]
737    name: Option<String>,
738    #[serde(default)]
739    claim: Option<String>,
740}
741
742impl<'de> Deserialize<'de> for KeySourceConfig {
743    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
744    where
745        D: serde::Deserializer<'de>,
746    {
747        use serde::de::Error;
748        let raw = KeySourceRaw::deserialize(deserializer)?;
749        match raw.kind.as_str() {
750            "ip" => {
751                if raw.name.is_some() || raw.claim.is_some() {
752                    return Err(D::Error::custom("key type 'ip' takes no other fields"));
753                }
754                Ok(Self::Ip)
755            }
756            "header" => {
757                if raw.claim.is_some() {
758                    return Err(D::Error::custom(
759                        "key type 'header' takes 'name', not 'claim'",
760                    ));
761                }
762                let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
763                Ok(Self::Header { name })
764            }
765            "jwt_claim" => {
766                if raw.name.is_some() {
767                    return Err(D::Error::custom(
768                        "key type 'jwt_claim' takes 'claim', not 'name'",
769                    ));
770                }
771                let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
772                Ok(Self::JwtClaim { claim })
773            }
774            other => Err(D::Error::unknown_variant(
775                other,
776                &["ip", "header", "jwt_claim"],
777            )),
778        }
779    }
780}
781
782/// Claims that carry a key's limit inside the JWT itself. A tier-name claim maps
783/// to a `profiles` entry (numbers stay tunable in config); direct numeric claims
784/// set the limit explicitly.
785#[derive(Debug, Clone, Deserialize)]
786#[serde(deny_unknown_fields)]
787#[non_exhaustive]
788pub struct JwtLimitConfig {
789    /// Claim naming a profile tier (e.g. `"premium"`). Default: `ratelimit_tier`.
790    #[serde(default = "default_tier_claim")]
791    pub tier_claim: String,
792    /// Claim carrying an explicit sustained rate, requests per minute. Default:
793    /// `ratelimit_rpm`.
794    #[serde(default = "default_rpm_claim")]
795    pub rpm_claim: String,
796    /// Claim carrying an explicit burst capacity. Default: `ratelimit_burst`.
797    #[serde(default = "default_burst_claim")]
798    pub burst_claim: String,
799}
800
801fn default_tier_claim() -> String {
802    "ratelimit_tier".to_string()
803}
804fn default_rpm_claim() -> String {
805    "ratelimit_rpm".to_string()
806}
807fn default_burst_claim() -> String {
808    "ratelimit_burst".to_string()
809}
810
811/// External limit-resolution service. The response names a tier or gives explicit
812/// numbers; results are cached and refreshed asynchronously, never on the request
813/// path.
814#[derive(Debug, Clone, Deserialize)]
815#[serde(deny_unknown_fields)]
816#[non_exhaustive]
817pub struct LimitServiceConfig {
818    /// HTTP endpoint queried with the limit key; returns `{ tier }` or
819    /// `{ rate_per_min, burst }`.
820    pub endpoint: String,
821    /// How long a resolved limit is cached before a background refresh, in
822    /// seconds (default: 300).
823    #[serde(default = "default_limit_ttl_secs")]
824    pub ttl_secs: u64,
825    /// Timeout for the background fetch, in milliseconds (default: 500).
826    #[serde(default = "default_limit_timeout_ms")]
827    pub timeout_ms: u64,
828}
829
830fn default_limit_ttl_secs() -> u64 {
831    300
832}
833fn default_limit_timeout_ms() -> u64 {
834    500
835}
836
837/// Asynchronous cross-instance reconciliation via a shared store.
838#[derive(Debug, Clone, Deserialize)]
839#[serde(deny_unknown_fields)]
840#[non_exhaustive]
841pub struct SyncConfig {
842    /// Shared-store URL (e.g. `"redis://127.0.0.1/"`). Requires the `redis` build
843    /// feature; without it the proxy logs a warning and stays local-only.
844    pub redis_url: String,
845    /// Background push/pull interval in milliseconds (default: 500). The
846    /// worst-case fleet overshoot is bounded by `(N-1) × rate × interval`.
847    #[serde(default = "default_sync_interval_ms")]
848    pub interval_ms: u64,
849}
850
851fn default_sync_interval_ms() -> u64 {
852    500
853}
854
855/// OIDC discovery config.
856#[derive(Debug, Clone, Deserialize)]
857#[non_exhaustive]
858pub struct OidcDiscoveryConfig {
859    #[serde(default)]
860    pub enabled: bool,
861    pub issuer: String,
862    #[serde(default)]
863    pub authorization_endpoint: Option<String>,
864    #[serde(default)]
865    pub token_endpoint: Option<String>,
866    #[serde(default)]
867    pub userinfo_endpoint: Option<String>,
868    #[serde(default)]
869    pub jwks_uri: Option<String>,
870    #[serde(default)]
871    pub signing_key: Option<SigningKeyConfig>,
872}
873
874/// Signing key config for JWKS endpoint.
875#[derive(Debug, Clone, Deserialize)]
876#[non_exhaustive]
877pub struct SigningKeyConfig {
878    #[serde(default = "default_algorithm")]
879    pub algorithm: String,
880    pub public_key_pem_file: PathBuf,
881}
882
883fn default_algorithm() -> String {
884    "EdDSA".into()
885}
886
887/// Health-probe endpoint configuration.
888///
889/// Paths are configurable so an embedder can relocate the probes (e.g. behind a
890/// `/internal/` prefix) or disable them when a fronting platform supplies its
891/// own. Defaults match the conventional `/health*` layout.
892#[derive(Debug, Clone, Deserialize)]
893#[non_exhaustive]
894pub struct HealthConfig {
895    /// Mount the health endpoints. Default: true.
896    #[serde(default = "default_true")]
897    pub enabled: bool,
898    /// Aggregate health endpoint. Default: `/health`.
899    #[serde(default = "default_health_path")]
900    pub path: String,
901    /// Liveness probe. Default: `/health/live`.
902    #[serde(default = "default_health_live_path")]
903    pub live_path: String,
904    /// Readiness probe (checks the upstream gRPC health). Default: `/health/ready`.
905    #[serde(default = "default_health_ready_path")]
906    pub ready_path: String,
907    /// Startup probe. Default: `/health/startup`.
908    #[serde(default = "default_health_startup_path")]
909    pub startup_path: String,
910}
911
912fn default_health_path() -> String {
913    "/health".into()
914}
915fn default_health_live_path() -> String {
916    "/health/live".into()
917}
918fn default_health_ready_path() -> String {
919    "/health/ready".into()
920}
921fn default_health_startup_path() -> String {
922    "/health/startup".into()
923}
924
925impl Default for HealthConfig {
926    fn default() -> Self {
927        Self {
928            enabled: true,
929            path: default_health_path(),
930            live_path: default_health_live_path(),
931            ready_path: default_health_ready_path(),
932            startup_path: default_health_startup_path(),
933        }
934    }
935}
936
937/// Prometheus metrics endpoint configuration.
938#[derive(Debug, Clone, Deserialize)]
939#[non_exhaustive]
940pub struct MetricsConfig {
941    /// Mount the metrics endpoint. Default: true.
942    #[serde(default = "default_true")]
943    pub enabled: bool,
944    /// Scrape path. Default: `/metrics`.
945    #[serde(default = "default_metrics_path")]
946    pub path: String,
947}
948
949fn default_metrics_path() -> String {
950    "/metrics".into()
951}
952
953impl Default for MetricsConfig {
954    fn default() -> Self {
955        Self {
956            enabled: true,
957            path: default_metrics_path(),
958        }
959    }
960}
961
962/// Maintenance mode config.
963#[derive(Debug, Clone, Deserialize)]
964#[non_exhaustive]
965pub struct MaintenanceConfig {
966    #[serde(default)]
967    pub enabled: bool,
968    /// Paths exempt from maintenance mode (glob patterns).
969    #[serde(default = "default_exempt_paths")]
970    pub exempt_paths: Vec<String>,
971    #[serde(default = "default_maintenance_message")]
972    pub message: String,
973}
974
975fn default_exempt_paths() -> Vec<String> {
976    vec![
977        "/health/**".into(),
978        "/.well-known/**".into(),
979        "/metrics".into(),
980        "/auth/verify".into(),
981    ]
982}
983
984fn default_maintenance_message() -> String {
985    "Service is under maintenance. Please try again later.".into()
986}
987
988impl Default for MaintenanceConfig {
989    fn default() -> Self {
990        Self {
991            enabled: false,
992            exempt_paths: default_exempt_paths(),
993            message: default_maintenance_message(),
994        }
995    }
996}
997
998/// CORS configuration.
999#[derive(Debug, Clone, Default, Deserialize)]
1000#[non_exhaustive]
1001pub struct CorsConfig {
1002    /// Allowed origins. Empty = permissive (dev mode).
1003    #[serde(default)]
1004    pub origins: Vec<String>,
1005}
1006
1007/// Logging configuration.
1008#[derive(Debug, Clone, Deserialize)]
1009#[non_exhaustive]
1010pub struct LoggingConfig {
1011    #[serde(default = "default_log_level")]
1012    pub level: String,
1013    #[serde(default = "default_log_format")]
1014    pub format: String,
1015}
1016
1017fn default_log_level() -> String {
1018    "info".into()
1019}
1020fn default_log_format() -> String {
1021    "json".into()
1022}
1023
1024impl Default for LoggingConfig {
1025    fn default() -> Self {
1026        Self {
1027            level: default_log_level(),
1028            format: default_log_format(),
1029        }
1030    }
1031}
1032
1033/// Metrics endpoint classification.
1034#[derive(Debug, Clone, Deserialize)]
1035#[non_exhaustive]
1036pub struct MetricsClassConfig {
1037    /// Glob pattern for path matching.
1038    pub pattern: String,
1039    /// Label value for this class.
1040    pub class: String,
1041}
1042
1043impl ProxyConfig {
1044    /// Load configuration from a YAML file.
1045    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1046        Self::from_yaml_str(&std::fs::read_to_string(path)?)
1047    }
1048
1049    /// Parse configuration from a YAML string.
1050    ///
1051    /// Useful for embedding the proxy: load a baked-in config (e.g. via
1052    /// `include_str!`) without touching the filesystem.
1053    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1054        let config: Self = serde_yaml::from_str(yaml)?;
1055        config.validate()?;
1056        Ok(config)
1057    }
1058
1059    /// Validate cross-field constraints that the type system can't express.
1060    ///
1061    /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
1062    /// directly when building a [`ProxyConfig`] programmatically so the same
1063    /// invariants are enforced on the embedded path.
1064    pub fn validate(&self) -> anyhow::Result<()> {
1065        if self.streaming.sse_keep_alive_secs == 0 {
1066            anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1067        }
1068        self.validate_edge_paths()?;
1069        Ok(())
1070    }
1071
1072    /// Reject malformed or duplicate built-in edge paths up front, so the router
1073    /// does not panic at construction (axum rejects a route that does not start
1074    /// with `/`, and panics on a path registered twice, e.g. setting
1075    /// `health.path` to the default `live_path`).
1076    fn validate_edge_paths(&self) -> anyhow::Result<()> {
1077        let mut seen = std::collections::HashSet::new();
1078        let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1079            if !path.starts_with('/') {
1080                anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1081            }
1082            if !seen.insert(path.to_string()) {
1083                anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1084            }
1085            Ok(())
1086        };
1087        if self.health.enabled {
1088            check("health.path", &self.health.path)?;
1089            check("health.live_path", &self.health.live_path)?;
1090            check("health.ready_path", &self.health.ready_path)?;
1091            check("health.startup_path", &self.health.startup_path)?;
1092        }
1093        if self.metrics.enabled {
1094            check("metrics.path", &self.metrics.path)?;
1095        }
1096        if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1097            check("openapi.path", &openapi.path)?;
1098            check("openapi.docs_path", &openapi.docs_path)?;
1099        }
1100        Ok(())
1101    }
1102
1103    /// Parse rate string like "20/min" → requests per window.
1104    pub fn parse_rate(rate: &str) -> Option<u32> {
1105        let parts: Vec<&str> = rate.split('/').collect();
1106        if parts.len() != 2 {
1107            return None;
1108        }
1109        parts[0].trim().parse().ok()
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests;