1use serde::Deserialize;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18 pub upstream: UpstreamConfig,
20
21 #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23 pub descriptors: Vec<DescriptorSource>,
24
25 #[serde(default)]
27 pub listen: ListenConfig,
28
29 #[serde(default)]
31 pub service: ServiceConfig,
32
33 #[serde(default)]
35 pub aliases: Vec<AliasConfig>,
36
37 #[serde(default)]
39 pub openapi: Option<OpenApiConfig>,
40
41 #[serde(default)]
43 pub auth: Option<AuthConfig>,
44
45 #[serde(default)]
47 pub shield: Option<ShieldConfig>,
48
49 #[serde(default)]
51 pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53 #[serde(default)]
55 pub health: HealthConfig,
56
57 #[serde(default)]
59 pub metrics: MetricsConfig,
60
61 #[serde(default)]
63 pub maintenance: MaintenanceConfig,
64
65 #[serde(default)]
67 pub cors: CorsConfig,
68
69 #[serde(default)]
71 pub logging: LoggingConfig,
72
73 #[serde(default)]
75 pub metrics_classes: Vec<MetricsClassConfig>,
76
77 #[serde(default = "default_forwarded_headers")]
79 pub forwarded_headers: Vec<String>,
80
81 #[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#[derive(Debug, Clone, Deserialize)]
106pub struct StreamingConfig {
107 #[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#[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#[derive(Debug, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct ResponseHeadersFileConfig {
162 #[serde(default)]
164 deny: Vec<String>,
165}
166
167#[derive(Debug, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct ErrorDetailsFileConfig {
172 #[serde(default = "default_true")]
174 enabled: bool,
175 #[serde(default)]
178 opaque: bool,
179 #[serde(default)]
182 routes: Vec<ErrorDetailsRouteFileConfig>,
183}
184
185#[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#[derive(Debug, Default, Deserialize)]
200struct StreamingFileConfig {
201 #[serde(default)]
202 ndjson_envelope: bool,
203}
204
205pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
208 "upstream",
209 "descriptors",
210 "listen",
211 "service",
212 "aliases",
213 "openapi",
214 "auth",
215 "shield",
216 "oidc_discovery",
217 "health",
218 "metrics",
219 "maintenance",
220 "cors",
221 "logging",
222 "metrics_classes",
223 "forwarded_headers",
224 "streaming",
225 "error_details",
226 "response_headers",
227];
228
229pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
232
233pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
238 let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
239 return Vec::new();
240 };
241 let mut unknown: Vec<String> = map
242 .keys()
243 .filter_map(|key| key.as_str())
244 .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
245 .map(str::to_owned)
246 .collect();
247 if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
250 unknown.extend(
251 streaming
252 .keys()
253 .filter_map(|key| key.as_str())
254 .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
255 .map(|key| format!("streaming.{key}")),
256 );
257 }
258 unknown
259}
260
261impl TranscodeFileConfig {
262 pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
270 use crate::transcode::error::ErrorDetailsPolicy;
271 let mut options = crate::transcode::TranscodeOptions::default()
272 .with_ndjson_envelope(self.streaming.ndjson_envelope);
273 if let Some(cfg) = &self.response_headers {
274 let deny = cfg
275 .deny
276 .iter()
277 .map(|name| {
278 http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
279 format!("response_headers.deny entry {name:?} is not a header name")
280 })
281 })
282 .collect::<Result<Vec<_>, _>>()?;
283 options = options.with_denied_response_headers(deny);
284 }
285 if let Some(cfg) = &self.error_details {
286 let base = if cfg.enabled {
287 ErrorDetailsPolicy::default()
288 } else {
289 ErrorDetailsPolicy::disabled()
290 }
291 .with_opaque_details(cfg.opaque);
292 let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
293 if rule.enabled.is_none() && rule.opaque.is_none() {
296 return Err(format!(
297 "error details route {:?} sets neither enabled nor opaque",
298 rule.pattern
299 ));
300 }
301 policy.rule(&rule.pattern, rule.enabled, rule.opaque)
302 })?;
303 options = options.with_error_details(policy);
304 }
305 Ok(options)
306 }
307}
308
309#[derive(Debug, Clone, Deserialize)]
311pub struct UpstreamConfig {
312 pub default: String,
314}
315
316#[derive(Debug, Clone)]
318pub enum DescriptorSource {
319 File { file: PathBuf },
321 Reflection { reflection: String },
323 Embedded { bytes: &'static [u8] },
325}
326
327#[derive(Debug, Clone, Deserialize)]
329#[serde(untagged)]
330enum DescriptorSourceYaml {
331 File { file: PathBuf },
332 Reflection { reflection: String },
333}
334
335impl From<DescriptorSourceYaml> for DescriptorSource {
336 fn from(yaml: DescriptorSourceYaml) -> Self {
337 match yaml {
338 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
339 DescriptorSourceYaml::Reflection { reflection } => {
340 DescriptorSource::Reflection { reflection }
341 }
342 }
343 }
344}
345
346fn deserialize_descriptor_sources<'de, D>(
347 deserializer: D,
348) -> std::result::Result<Vec<DescriptorSource>, D::Error>
349where
350 D: serde::Deserializer<'de>,
351{
352 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
353 Ok(yaml_sources.into_iter().map(Into::into).collect())
354}
355
356#[derive(Debug, Clone, Deserialize)]
358pub struct ListenConfig {
359 #[serde(default = "default_http_listen")]
361 pub http: String,
362}
363
364fn default_http_listen() -> String {
365 "0.0.0.0:8080".into()
366}
367
368impl Default for ListenConfig {
369 fn default() -> Self {
370 Self {
371 http: default_http_listen(),
372 }
373 }
374}
375
376#[derive(Debug, Clone, Deserialize)]
378pub struct ServiceConfig {
379 #[serde(default = "default_service_name")]
381 pub name: String,
382}
383
384fn default_service_name() -> String {
385 "structured-proxy".into()
386}
387
388impl Default for ServiceConfig {
389 fn default() -> Self {
390 Self {
391 name: default_service_name(),
392 }
393 }
394}
395
396#[derive(Debug, Clone, Deserialize)]
398#[non_exhaustive]
399pub struct AliasConfig {
400 pub from: String,
401 pub to: String,
402}
403
404#[derive(Debug, Clone, Deserialize)]
406#[non_exhaustive]
407pub struct OpenApiConfig {
408 #[serde(default = "default_true")]
409 pub enabled: bool,
410 #[serde(default = "default_openapi_path")]
412 pub path: String,
413 #[serde(default = "default_docs_path")]
415 pub docs_path: String,
416 #[serde(default)]
417 pub title: Option<String>,
418 #[serde(default)]
419 pub version: Option<String>,
420}
421
422fn default_openapi_path() -> String {
423 "/openapi.json".into()
424}
425
426fn default_docs_path() -> String {
427 "/docs".into()
428}
429
430fn default_true() -> bool {
431 true
432}
433
434#[derive(Debug, Clone, Deserialize)]
436#[non_exhaustive]
437pub struct AuthConfig {
438 #[serde(default = "default_auth_mode")]
440 pub mode: String,
441
442 #[serde(default)]
444 pub jwt: Option<JwtConfig>,
445
446 #[serde(default)]
448 pub forward_auth: Option<ForwardAuthConfig>,
449
450 #[serde(default)]
452 pub authz: Option<AuthzConfig>,
453}
454
455fn default_auth_mode() -> String {
456 "none".into()
457}
458
459#[derive(Debug, Clone, Deserialize)]
461#[non_exhaustive]
462pub struct JwtConfig {
463 #[serde(default)]
465 pub jwks_uri: Option<String>,
466 #[serde(default)]
468 pub issuer: Option<String>,
469 #[serde(default)]
471 pub audience: Option<String>,
472 #[serde(default)]
474 pub public_key_pem_file: Option<PathBuf>,
475 #[serde(default)]
477 pub claims_headers: std::collections::HashMap<String, String>,
478 #[serde(default = "default_roles_claim")]
481 pub roles_claim: String,
482}
483
484pub(crate) fn default_roles_claim() -> String {
487 "roles".into()
488}
489
490#[derive(Debug, Clone, Deserialize)]
492#[non_exhaustive]
493pub struct ForwardAuthConfig {
494 #[serde(default)]
495 pub enabled: bool,
496 #[serde(default = "default_forward_auth_path")]
497 pub path: String,
498 #[serde(default)]
500 pub policies: Vec<RoutePolicyConfig>,
501 #[serde(default)]
503 pub login_url: Option<String>,
504 #[serde(default)]
506 pub applications_path: Option<PathBuf>,
507}
508
509fn default_forward_auth_path() -> String {
510 "/auth/verify".into()
511}
512
513#[derive(Debug, Clone, Deserialize)]
515#[non_exhaustive]
516pub struct RoutePolicyConfig {
517 pub path: String,
518 #[serde(default = "default_methods_all")]
519 pub methods: Vec<String>,
520 #[serde(default)]
521 pub require_auth: bool,
522 #[serde(default)]
523 pub required_roles: Vec<String>,
524}
525
526fn default_methods_all() -> Vec<String> {
527 vec!["*".into()]
528}
529
530#[derive(Debug, Clone, Deserialize)]
534#[non_exhaustive]
535pub struct AuthzConfig {
536 #[serde(default)]
538 pub enabled: bool,
539 #[serde(default)]
542 pub endpoint: String,
543 #[serde(default = "default_authz_timeout_ms")]
545 pub timeout_ms: u64,
546 #[serde(default)]
549 pub failure_mode_allow: bool,
550}
551
552fn default_authz_timeout_ms() -> u64 {
553 200
554}
555
556#[derive(Debug, Clone, Deserialize)]
564#[serde(deny_unknown_fields)]
565#[non_exhaustive]
566pub struct ShieldConfig {
567 #[serde(default)]
568 pub enabled: bool,
569 #[serde(default)]
572 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
573 #[serde(default)]
576 pub rules: Vec<RateRuleConfig>,
577 #[serde(default)]
581 pub default_profile: Option<String>,
582 #[serde(default)]
585 pub jwt_limits: Option<JwtLimitConfig>,
586 #[serde(default)]
589 pub limit_service: Option<LimitServiceConfig>,
590 #[serde(default)]
593 pub sync: Option<SyncConfig>,
594 #[serde(default)]
600 pub trusted_proxies: Vec<String>,
601}
602
603#[derive(Debug, Clone, Deserialize)]
605#[serde(deny_unknown_fields)]
606#[non_exhaustive]
607pub struct LimitProfileConfig {
608 pub rate: String,
611 #[serde(default)]
614 pub burst: Option<u64>,
615}
616
617#[derive(Debug, Clone, Deserialize)]
624#[serde(deny_unknown_fields)]
625#[non_exhaustive]
626pub struct RateRuleConfig {
627 pub pattern: String,
629 #[serde(default)]
631 pub key: KeySourceConfig,
632 #[serde(default)]
635 pub profile: Option<String>,
636}
637
638#[derive(Debug, Clone, Default, PartialEq, Eq)]
643#[non_exhaustive]
644pub enum KeySourceConfig {
645 #[default]
647 Ip,
648 Header {
651 name: String,
653 },
654 JwtClaim {
657 claim: String,
659 },
660}
661
662#[derive(Deserialize)]
669#[serde(deny_unknown_fields, rename_all = "snake_case")]
670struct KeySourceRaw {
671 #[serde(rename = "type")]
672 kind: String,
673 #[serde(default)]
674 name: Option<String>,
675 #[serde(default)]
676 claim: Option<String>,
677}
678
679impl<'de> Deserialize<'de> for KeySourceConfig {
680 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
681 where
682 D: serde::Deserializer<'de>,
683 {
684 use serde::de::Error;
685 let raw = KeySourceRaw::deserialize(deserializer)?;
686 match raw.kind.as_str() {
687 "ip" => {
688 if raw.name.is_some() || raw.claim.is_some() {
689 return Err(D::Error::custom("key type 'ip' takes no other fields"));
690 }
691 Ok(Self::Ip)
692 }
693 "header" => {
694 if raw.claim.is_some() {
695 return Err(D::Error::custom(
696 "key type 'header' takes 'name', not 'claim'",
697 ));
698 }
699 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
700 Ok(Self::Header { name })
701 }
702 "jwt_claim" => {
703 if raw.name.is_some() {
704 return Err(D::Error::custom(
705 "key type 'jwt_claim' takes 'claim', not 'name'",
706 ));
707 }
708 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
709 Ok(Self::JwtClaim { claim })
710 }
711 other => Err(D::Error::unknown_variant(
712 other,
713 &["ip", "header", "jwt_claim"],
714 )),
715 }
716 }
717}
718
719#[derive(Debug, Clone, Deserialize)]
723#[serde(deny_unknown_fields)]
724#[non_exhaustive]
725pub struct JwtLimitConfig {
726 #[serde(default = "default_tier_claim")]
728 pub tier_claim: String,
729 #[serde(default = "default_rpm_claim")]
732 pub rpm_claim: String,
733 #[serde(default = "default_burst_claim")]
735 pub burst_claim: String,
736}
737
738fn default_tier_claim() -> String {
739 "ratelimit_tier".to_string()
740}
741fn default_rpm_claim() -> String {
742 "ratelimit_rpm".to_string()
743}
744fn default_burst_claim() -> String {
745 "ratelimit_burst".to_string()
746}
747
748#[derive(Debug, Clone, Deserialize)]
752#[serde(deny_unknown_fields)]
753#[non_exhaustive]
754pub struct LimitServiceConfig {
755 pub endpoint: String,
758 #[serde(default = "default_limit_ttl_secs")]
761 pub ttl_secs: u64,
762 #[serde(default = "default_limit_timeout_ms")]
764 pub timeout_ms: u64,
765}
766
767fn default_limit_ttl_secs() -> u64 {
768 300
769}
770fn default_limit_timeout_ms() -> u64 {
771 500
772}
773
774#[derive(Debug, Clone, Deserialize)]
776#[serde(deny_unknown_fields)]
777#[non_exhaustive]
778pub struct SyncConfig {
779 pub redis_url: String,
782 #[serde(default = "default_sync_interval_ms")]
785 pub interval_ms: u64,
786}
787
788fn default_sync_interval_ms() -> u64 {
789 500
790}
791
792#[derive(Debug, Clone, Deserialize)]
794#[non_exhaustive]
795pub struct OidcDiscoveryConfig {
796 #[serde(default)]
797 pub enabled: bool,
798 pub issuer: String,
799 #[serde(default)]
800 pub authorization_endpoint: Option<String>,
801 #[serde(default)]
802 pub token_endpoint: Option<String>,
803 #[serde(default)]
804 pub userinfo_endpoint: Option<String>,
805 #[serde(default)]
806 pub jwks_uri: Option<String>,
807 #[serde(default)]
808 pub signing_key: Option<SigningKeyConfig>,
809}
810
811#[derive(Debug, Clone, Deserialize)]
813#[non_exhaustive]
814pub struct SigningKeyConfig {
815 #[serde(default = "default_algorithm")]
816 pub algorithm: String,
817 pub public_key_pem_file: PathBuf,
818}
819
820fn default_algorithm() -> String {
821 "EdDSA".into()
822}
823
824#[derive(Debug, Clone, Deserialize)]
830#[non_exhaustive]
831pub struct HealthConfig {
832 #[serde(default = "default_true")]
834 pub enabled: bool,
835 #[serde(default = "default_health_path")]
837 pub path: String,
838 #[serde(default = "default_health_live_path")]
840 pub live_path: String,
841 #[serde(default = "default_health_ready_path")]
843 pub ready_path: String,
844 #[serde(default = "default_health_startup_path")]
846 pub startup_path: String,
847}
848
849fn default_health_path() -> String {
850 "/health".into()
851}
852fn default_health_live_path() -> String {
853 "/health/live".into()
854}
855fn default_health_ready_path() -> String {
856 "/health/ready".into()
857}
858fn default_health_startup_path() -> String {
859 "/health/startup".into()
860}
861
862impl Default for HealthConfig {
863 fn default() -> Self {
864 Self {
865 enabled: true,
866 path: default_health_path(),
867 live_path: default_health_live_path(),
868 ready_path: default_health_ready_path(),
869 startup_path: default_health_startup_path(),
870 }
871 }
872}
873
874#[derive(Debug, Clone, Deserialize)]
876#[non_exhaustive]
877pub struct MetricsConfig {
878 #[serde(default = "default_true")]
880 pub enabled: bool,
881 #[serde(default = "default_metrics_path")]
883 pub path: String,
884}
885
886fn default_metrics_path() -> String {
887 "/metrics".into()
888}
889
890impl Default for MetricsConfig {
891 fn default() -> Self {
892 Self {
893 enabled: true,
894 path: default_metrics_path(),
895 }
896 }
897}
898
899#[derive(Debug, Clone, Deserialize)]
901#[non_exhaustive]
902pub struct MaintenanceConfig {
903 #[serde(default)]
904 pub enabled: bool,
905 #[serde(default = "default_exempt_paths")]
907 pub exempt_paths: Vec<String>,
908 #[serde(default = "default_maintenance_message")]
909 pub message: String,
910}
911
912fn default_exempt_paths() -> Vec<String> {
913 vec![
914 "/health/**".into(),
915 "/.well-known/**".into(),
916 "/metrics".into(),
917 "/auth/verify".into(),
918 ]
919}
920
921fn default_maintenance_message() -> String {
922 "Service is under maintenance. Please try again later.".into()
923}
924
925impl Default for MaintenanceConfig {
926 fn default() -> Self {
927 Self {
928 enabled: false,
929 exempt_paths: default_exempt_paths(),
930 message: default_maintenance_message(),
931 }
932 }
933}
934
935#[derive(Debug, Clone, Default, Deserialize)]
937#[non_exhaustive]
938pub struct CorsConfig {
939 #[serde(default)]
941 pub origins: Vec<String>,
942}
943
944#[derive(Debug, Clone, Deserialize)]
946#[non_exhaustive]
947pub struct LoggingConfig {
948 #[serde(default = "default_log_level")]
949 pub level: String,
950 #[serde(default = "default_log_format")]
951 pub format: String,
952}
953
954fn default_log_level() -> String {
955 "info".into()
956}
957fn default_log_format() -> String {
958 "json".into()
959}
960
961impl Default for LoggingConfig {
962 fn default() -> Self {
963 Self {
964 level: default_log_level(),
965 format: default_log_format(),
966 }
967 }
968}
969
970#[derive(Debug, Clone, Deserialize)]
972#[non_exhaustive]
973pub struct MetricsClassConfig {
974 pub pattern: String,
976 pub class: String,
978}
979
980impl ProxyConfig {
981 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
983 Self::from_yaml_str(&std::fs::read_to_string(path)?)
984 }
985
986 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
991 let config: Self = serde_yaml::from_str(yaml)?;
992 config.validate()?;
993 Ok(config)
994 }
995
996 pub fn validate(&self) -> anyhow::Result<()> {
1002 if self.streaming.sse_keep_alive_secs == 0 {
1003 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1004 }
1005 self.validate_edge_paths()?;
1006 Ok(())
1007 }
1008
1009 fn validate_edge_paths(&self) -> anyhow::Result<()> {
1014 let mut seen = std::collections::HashSet::new();
1015 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1016 if !path.starts_with('/') {
1017 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1018 }
1019 if !seen.insert(path.to_string()) {
1020 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1021 }
1022 Ok(())
1023 };
1024 if self.health.enabled {
1025 check("health.path", &self.health.path)?;
1026 check("health.live_path", &self.health.live_path)?;
1027 check("health.ready_path", &self.health.ready_path)?;
1028 check("health.startup_path", &self.health.startup_path)?;
1029 }
1030 if self.metrics.enabled {
1031 check("metrics.path", &self.metrics.path)?;
1032 }
1033 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1034 check("openapi.path", &openapi.path)?;
1035 check("openapi.docs_path", &openapi.docs_path)?;
1036 }
1037 Ok(())
1038 }
1039
1040 pub fn parse_rate(rate: &str) -> Option<u32> {
1042 let parts: Vec<&str> = rate.split('/').collect();
1043 if parts.len() != 2 {
1044 return None;
1045 }
1046 parts[0].trim().parse().ok()
1047 }
1048}
1049
1050#[cfg(test)]
1051mod tests;