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