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)]
146pub(crate) struct TranscodeFileConfig {
147 #[serde(default)]
148 error_details: Option<ErrorDetailsFileConfig>,
149 #[serde(default)]
150 streaming: StreamingFileConfig,
151}
152
153#[derive(Debug, Deserialize)]
156#[serde(deny_unknown_fields)]
157struct ErrorDetailsFileConfig {
158 #[serde(default = "default_true")]
160 enabled: bool,
161 #[serde(default)]
164 opaque: bool,
165 #[serde(default)]
168 routes: Vec<ErrorDetailsRouteFileConfig>,
169}
170
171#[derive(Debug, Deserialize)]
173#[serde(deny_unknown_fields)]
174struct ErrorDetailsRouteFileConfig {
175 pattern: String,
176 #[serde(default)]
177 enabled: Option<bool>,
178 #[serde(default)]
179 opaque: Option<bool>,
180}
181
182#[derive(Debug, Default, Deserialize)]
186struct StreamingFileConfig {
187 #[serde(default)]
188 ndjson_envelope: bool,
189}
190
191pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
194 "upstream",
195 "descriptors",
196 "listen",
197 "service",
198 "aliases",
199 "openapi",
200 "auth",
201 "shield",
202 "oidc_discovery",
203 "health",
204 "metrics",
205 "maintenance",
206 "cors",
207 "logging",
208 "metrics_classes",
209 "forwarded_headers",
210 "streaming",
211 "error_details",
212];
213
214pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
217
218pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
223 let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
224 return Vec::new();
225 };
226 let mut unknown: Vec<String> = map
227 .keys()
228 .filter_map(|key| key.as_str())
229 .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
230 .map(str::to_owned)
231 .collect();
232 if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
235 unknown.extend(
236 streaming
237 .keys()
238 .filter_map(|key| key.as_str())
239 .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
240 .map(|key| format!("streaming.{key}")),
241 );
242 }
243 unknown
244}
245
246impl TranscodeFileConfig {
247 pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
254 use crate::transcode::error::ErrorDetailsPolicy;
255 let mut options = crate::transcode::TranscodeOptions::default()
256 .with_ndjson_envelope(self.streaming.ndjson_envelope);
257 if let Some(cfg) = &self.error_details {
258 let base = if cfg.enabled {
259 ErrorDetailsPolicy::default()
260 } else {
261 ErrorDetailsPolicy::disabled()
262 }
263 .with_opaque_details(cfg.opaque);
264 let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
265 if rule.enabled.is_none() && rule.opaque.is_none() {
268 return Err(format!(
269 "error details route {:?} sets neither enabled nor opaque",
270 rule.pattern
271 ));
272 }
273 policy.rule(&rule.pattern, rule.enabled, rule.opaque)
274 })?;
275 options = options.with_error_details(policy);
276 }
277 Ok(options)
278 }
279}
280
281#[derive(Debug, Clone, Deserialize)]
283pub struct UpstreamConfig {
284 pub default: String,
286}
287
288#[derive(Debug, Clone)]
290pub enum DescriptorSource {
291 File { file: PathBuf },
293 Reflection { reflection: String },
295 Embedded { bytes: &'static [u8] },
297}
298
299#[derive(Debug, Clone, Deserialize)]
301#[serde(untagged)]
302enum DescriptorSourceYaml {
303 File { file: PathBuf },
304 Reflection { reflection: String },
305}
306
307impl From<DescriptorSourceYaml> for DescriptorSource {
308 fn from(yaml: DescriptorSourceYaml) -> Self {
309 match yaml {
310 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
311 DescriptorSourceYaml::Reflection { reflection } => {
312 DescriptorSource::Reflection { reflection }
313 }
314 }
315 }
316}
317
318fn deserialize_descriptor_sources<'de, D>(
319 deserializer: D,
320) -> std::result::Result<Vec<DescriptorSource>, D::Error>
321where
322 D: serde::Deserializer<'de>,
323{
324 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
325 Ok(yaml_sources.into_iter().map(Into::into).collect())
326}
327
328#[derive(Debug, Clone, Deserialize)]
330pub struct ListenConfig {
331 #[serde(default = "default_http_listen")]
333 pub http: String,
334}
335
336fn default_http_listen() -> String {
337 "0.0.0.0:8080".into()
338}
339
340impl Default for ListenConfig {
341 fn default() -> Self {
342 Self {
343 http: default_http_listen(),
344 }
345 }
346}
347
348#[derive(Debug, Clone, Deserialize)]
350pub struct ServiceConfig {
351 #[serde(default = "default_service_name")]
353 pub name: String,
354}
355
356fn default_service_name() -> String {
357 "structured-proxy".into()
358}
359
360impl Default for ServiceConfig {
361 fn default() -> Self {
362 Self {
363 name: default_service_name(),
364 }
365 }
366}
367
368#[derive(Debug, Clone, Deserialize)]
370#[non_exhaustive]
371pub struct AliasConfig {
372 pub from: String,
373 pub to: String,
374}
375
376#[derive(Debug, Clone, Deserialize)]
378#[non_exhaustive]
379pub struct OpenApiConfig {
380 #[serde(default = "default_true")]
381 pub enabled: bool,
382 #[serde(default = "default_openapi_path")]
384 pub path: String,
385 #[serde(default = "default_docs_path")]
387 pub docs_path: String,
388 #[serde(default)]
389 pub title: Option<String>,
390 #[serde(default)]
391 pub version: Option<String>,
392}
393
394fn default_openapi_path() -> String {
395 "/openapi.json".into()
396}
397
398fn default_docs_path() -> String {
399 "/docs".into()
400}
401
402fn default_true() -> bool {
403 true
404}
405
406#[derive(Debug, Clone, Deserialize)]
408#[non_exhaustive]
409pub struct AuthConfig {
410 #[serde(default = "default_auth_mode")]
412 pub mode: String,
413
414 #[serde(default)]
416 pub jwt: Option<JwtConfig>,
417
418 #[serde(default)]
420 pub forward_auth: Option<ForwardAuthConfig>,
421
422 #[serde(default)]
424 pub authz: Option<AuthzConfig>,
425}
426
427fn default_auth_mode() -> String {
428 "none".into()
429}
430
431#[derive(Debug, Clone, Deserialize)]
433#[non_exhaustive]
434pub struct JwtConfig {
435 #[serde(default)]
437 pub jwks_uri: Option<String>,
438 #[serde(default)]
440 pub issuer: Option<String>,
441 #[serde(default)]
443 pub audience: Option<String>,
444 #[serde(default)]
446 pub public_key_pem_file: Option<PathBuf>,
447 #[serde(default)]
449 pub claims_headers: std::collections::HashMap<String, String>,
450 #[serde(default = "default_roles_claim")]
453 pub roles_claim: String,
454}
455
456pub(crate) fn default_roles_claim() -> String {
459 "roles".into()
460}
461
462#[derive(Debug, Clone, Deserialize)]
464#[non_exhaustive]
465pub struct ForwardAuthConfig {
466 #[serde(default)]
467 pub enabled: bool,
468 #[serde(default = "default_forward_auth_path")]
469 pub path: String,
470 #[serde(default)]
472 pub policies: Vec<RoutePolicyConfig>,
473 #[serde(default)]
475 pub login_url: Option<String>,
476 #[serde(default)]
478 pub applications_path: Option<PathBuf>,
479}
480
481fn default_forward_auth_path() -> String {
482 "/auth/verify".into()
483}
484
485#[derive(Debug, Clone, Deserialize)]
487#[non_exhaustive]
488pub struct RoutePolicyConfig {
489 pub path: String,
490 #[serde(default = "default_methods_all")]
491 pub methods: Vec<String>,
492 #[serde(default)]
493 pub require_auth: bool,
494 #[serde(default)]
495 pub required_roles: Vec<String>,
496}
497
498fn default_methods_all() -> Vec<String> {
499 vec!["*".into()]
500}
501
502#[derive(Debug, Clone, Deserialize)]
506#[non_exhaustive]
507pub struct AuthzConfig {
508 #[serde(default)]
510 pub enabled: bool,
511 #[serde(default)]
514 pub endpoint: String,
515 #[serde(default = "default_authz_timeout_ms")]
517 pub timeout_ms: u64,
518 #[serde(default)]
521 pub failure_mode_allow: bool,
522}
523
524fn default_authz_timeout_ms() -> u64 {
525 200
526}
527
528#[derive(Debug, Clone, Deserialize)]
536#[serde(deny_unknown_fields)]
537#[non_exhaustive]
538pub struct ShieldConfig {
539 #[serde(default)]
540 pub enabled: bool,
541 #[serde(default)]
544 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
545 #[serde(default)]
548 pub rules: Vec<RateRuleConfig>,
549 #[serde(default)]
553 pub default_profile: Option<String>,
554 #[serde(default)]
557 pub jwt_limits: Option<JwtLimitConfig>,
558 #[serde(default)]
561 pub limit_service: Option<LimitServiceConfig>,
562 #[serde(default)]
565 pub sync: Option<SyncConfig>,
566 #[serde(default)]
572 pub trusted_proxies: Vec<String>,
573}
574
575#[derive(Debug, Clone, Deserialize)]
577#[serde(deny_unknown_fields)]
578#[non_exhaustive]
579pub struct LimitProfileConfig {
580 pub rate: String,
583 #[serde(default)]
586 pub burst: Option<u64>,
587}
588
589#[derive(Debug, Clone, Deserialize)]
596#[serde(deny_unknown_fields)]
597#[non_exhaustive]
598pub struct RateRuleConfig {
599 pub pattern: String,
601 #[serde(default)]
603 pub key: KeySourceConfig,
604 #[serde(default)]
607 pub profile: Option<String>,
608}
609
610#[derive(Debug, Clone, Default, PartialEq, Eq)]
615#[non_exhaustive]
616pub enum KeySourceConfig {
617 #[default]
619 Ip,
620 Header {
623 name: String,
625 },
626 JwtClaim {
629 claim: String,
631 },
632}
633
634#[derive(Deserialize)]
641#[serde(deny_unknown_fields, rename_all = "snake_case")]
642struct KeySourceRaw {
643 #[serde(rename = "type")]
644 kind: String,
645 #[serde(default)]
646 name: Option<String>,
647 #[serde(default)]
648 claim: Option<String>,
649}
650
651impl<'de> Deserialize<'de> for KeySourceConfig {
652 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
653 where
654 D: serde::Deserializer<'de>,
655 {
656 use serde::de::Error;
657 let raw = KeySourceRaw::deserialize(deserializer)?;
658 match raw.kind.as_str() {
659 "ip" => {
660 if raw.name.is_some() || raw.claim.is_some() {
661 return Err(D::Error::custom("key type 'ip' takes no other fields"));
662 }
663 Ok(Self::Ip)
664 }
665 "header" => {
666 if raw.claim.is_some() {
667 return Err(D::Error::custom(
668 "key type 'header' takes 'name', not 'claim'",
669 ));
670 }
671 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
672 Ok(Self::Header { name })
673 }
674 "jwt_claim" => {
675 if raw.name.is_some() {
676 return Err(D::Error::custom(
677 "key type 'jwt_claim' takes 'claim', not 'name'",
678 ));
679 }
680 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
681 Ok(Self::JwtClaim { claim })
682 }
683 other => Err(D::Error::unknown_variant(
684 other,
685 &["ip", "header", "jwt_claim"],
686 )),
687 }
688 }
689}
690
691#[derive(Debug, Clone, Deserialize)]
695#[serde(deny_unknown_fields)]
696#[non_exhaustive]
697pub struct JwtLimitConfig {
698 #[serde(default = "default_tier_claim")]
700 pub tier_claim: String,
701 #[serde(default = "default_rpm_claim")]
704 pub rpm_claim: String,
705 #[serde(default = "default_burst_claim")]
707 pub burst_claim: String,
708}
709
710fn default_tier_claim() -> String {
711 "ratelimit_tier".to_string()
712}
713fn default_rpm_claim() -> String {
714 "ratelimit_rpm".to_string()
715}
716fn default_burst_claim() -> String {
717 "ratelimit_burst".to_string()
718}
719
720#[derive(Debug, Clone, Deserialize)]
724#[serde(deny_unknown_fields)]
725#[non_exhaustive]
726pub struct LimitServiceConfig {
727 pub endpoint: String,
730 #[serde(default = "default_limit_ttl_secs")]
733 pub ttl_secs: u64,
734 #[serde(default = "default_limit_timeout_ms")]
736 pub timeout_ms: u64,
737}
738
739fn default_limit_ttl_secs() -> u64 {
740 300
741}
742fn default_limit_timeout_ms() -> u64 {
743 500
744}
745
746#[derive(Debug, Clone, Deserialize)]
748#[serde(deny_unknown_fields)]
749#[non_exhaustive]
750pub struct SyncConfig {
751 pub redis_url: String,
754 #[serde(default = "default_sync_interval_ms")]
757 pub interval_ms: u64,
758}
759
760fn default_sync_interval_ms() -> u64 {
761 500
762}
763
764#[derive(Debug, Clone, Deserialize)]
766#[non_exhaustive]
767pub struct OidcDiscoveryConfig {
768 #[serde(default)]
769 pub enabled: bool,
770 pub issuer: String,
771 #[serde(default)]
772 pub authorization_endpoint: Option<String>,
773 #[serde(default)]
774 pub token_endpoint: Option<String>,
775 #[serde(default)]
776 pub userinfo_endpoint: Option<String>,
777 #[serde(default)]
778 pub jwks_uri: Option<String>,
779 #[serde(default)]
780 pub signing_key: Option<SigningKeyConfig>,
781}
782
783#[derive(Debug, Clone, Deserialize)]
785#[non_exhaustive]
786pub struct SigningKeyConfig {
787 #[serde(default = "default_algorithm")]
788 pub algorithm: String,
789 pub public_key_pem_file: PathBuf,
790}
791
792fn default_algorithm() -> String {
793 "EdDSA".into()
794}
795
796#[derive(Debug, Clone, Deserialize)]
802#[non_exhaustive]
803pub struct HealthConfig {
804 #[serde(default = "default_true")]
806 pub enabled: bool,
807 #[serde(default = "default_health_path")]
809 pub path: String,
810 #[serde(default = "default_health_live_path")]
812 pub live_path: String,
813 #[serde(default = "default_health_ready_path")]
815 pub ready_path: String,
816 #[serde(default = "default_health_startup_path")]
818 pub startup_path: String,
819}
820
821fn default_health_path() -> String {
822 "/health".into()
823}
824fn default_health_live_path() -> String {
825 "/health/live".into()
826}
827fn default_health_ready_path() -> String {
828 "/health/ready".into()
829}
830fn default_health_startup_path() -> String {
831 "/health/startup".into()
832}
833
834impl Default for HealthConfig {
835 fn default() -> Self {
836 Self {
837 enabled: true,
838 path: default_health_path(),
839 live_path: default_health_live_path(),
840 ready_path: default_health_ready_path(),
841 startup_path: default_health_startup_path(),
842 }
843 }
844}
845
846#[derive(Debug, Clone, Deserialize)]
848#[non_exhaustive]
849pub struct MetricsConfig {
850 #[serde(default = "default_true")]
852 pub enabled: bool,
853 #[serde(default = "default_metrics_path")]
855 pub path: String,
856}
857
858fn default_metrics_path() -> String {
859 "/metrics".into()
860}
861
862impl Default for MetricsConfig {
863 fn default() -> Self {
864 Self {
865 enabled: true,
866 path: default_metrics_path(),
867 }
868 }
869}
870
871#[derive(Debug, Clone, Deserialize)]
873#[non_exhaustive]
874pub struct MaintenanceConfig {
875 #[serde(default)]
876 pub enabled: bool,
877 #[serde(default = "default_exempt_paths")]
879 pub exempt_paths: Vec<String>,
880 #[serde(default = "default_maintenance_message")]
881 pub message: String,
882}
883
884fn default_exempt_paths() -> Vec<String> {
885 vec![
886 "/health/**".into(),
887 "/.well-known/**".into(),
888 "/metrics".into(),
889 "/auth/verify".into(),
890 ]
891}
892
893fn default_maintenance_message() -> String {
894 "Service is under maintenance. Please try again later.".into()
895}
896
897impl Default for MaintenanceConfig {
898 fn default() -> Self {
899 Self {
900 enabled: false,
901 exempt_paths: default_exempt_paths(),
902 message: default_maintenance_message(),
903 }
904 }
905}
906
907#[derive(Debug, Clone, Default, Deserialize)]
909#[non_exhaustive]
910pub struct CorsConfig {
911 #[serde(default)]
913 pub origins: Vec<String>,
914}
915
916#[derive(Debug, Clone, Deserialize)]
918#[non_exhaustive]
919pub struct LoggingConfig {
920 #[serde(default = "default_log_level")]
921 pub level: String,
922 #[serde(default = "default_log_format")]
923 pub format: String,
924}
925
926fn default_log_level() -> String {
927 "info".into()
928}
929fn default_log_format() -> String {
930 "json".into()
931}
932
933impl Default for LoggingConfig {
934 fn default() -> Self {
935 Self {
936 level: default_log_level(),
937 format: default_log_format(),
938 }
939 }
940}
941
942#[derive(Debug, Clone, Deserialize)]
944#[non_exhaustive]
945pub struct MetricsClassConfig {
946 pub pattern: String,
948 pub class: String,
950}
951
952impl ProxyConfig {
953 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
955 Self::from_yaml_str(&std::fs::read_to_string(path)?)
956 }
957
958 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
963 let config: Self = serde_yaml::from_str(yaml)?;
964 config.validate()?;
965 Ok(config)
966 }
967
968 pub fn validate(&self) -> anyhow::Result<()> {
974 if self.streaming.sse_keep_alive_secs == 0 {
975 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
976 }
977 self.validate_edge_paths()?;
978 Ok(())
979 }
980
981 fn validate_edge_paths(&self) -> anyhow::Result<()> {
986 let mut seen = std::collections::HashSet::new();
987 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
988 if !path.starts_with('/') {
989 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
990 }
991 if !seen.insert(path.to_string()) {
992 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
993 }
994 Ok(())
995 };
996 if self.health.enabled {
997 check("health.path", &self.health.path)?;
998 check("health.live_path", &self.health.live_path)?;
999 check("health.ready_path", &self.health.ready_path)?;
1000 check("health.startup_path", &self.health.startup_path)?;
1001 }
1002 if self.metrics.enabled {
1003 check("metrics.path", &self.metrics.path)?;
1004 }
1005 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1006 check("openapi.path", &openapi.path)?;
1007 check("openapi.docs_path", &openapi.docs_path)?;
1008 }
1009 Ok(())
1010 }
1011
1012 pub fn parse_rate(rate: &str) -> Option<u32> {
1014 let parts: Vec<&str> = rate.split('/').collect();
1015 if parts.len() != 2 {
1016 return None;
1017 }
1018 parts[0].trim().parse().ok()
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests;