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