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