soma_gateway/config/
protected_routes.rs1use serde::{Deserialize, Serialize};
2
3use super::ConfigError;
4use crate::config::upstream::default_true;
5
6#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7pub struct ProtectedGatewaySubsetTarget {
8 #[serde(default, skip_serializing_if = "Vec::is_empty")]
9 pub upstreams: Vec<String>,
10 #[serde(default, skip_serializing_if = "Vec::is_empty")]
11 pub services: Vec<String>,
12 #[serde(default)]
13 pub expose_code_mode: bool,
14}
15
16fn default_protected_route_scopes() -> Vec<String> {
17 Vec::new()
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ProtectedMcpRouteConfig {
22 pub name: String,
23 #[serde(default = "default_true")]
24 pub enabled: bool,
25 pub public_host: String,
26 pub public_path: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub upstream: Option<String>,
29 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub backend_url: String,
31 #[serde(default = "default_protected_route_scopes")]
32 pub scopes: Vec<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub target: Option<ProtectedGatewaySubsetTarget>,
35}
36
37impl Default for ProtectedMcpRouteConfig {
38 fn default() -> Self {
39 Self {
40 name: String::new(),
41 enabled: true,
42 public_host: String::new(),
43 public_path: "/mcp".to_owned(),
44 upstream: None,
45 backend_url: String::new(),
46 scopes: default_protected_route_scopes(),
47 target: None,
48 }
49 }
50}
51
52impl ProtectedMcpRouteConfig {
53 pub fn validate(&self) -> Result<(), ConfigError> {
54 if self.name.trim().is_empty() {
55 return Err(ConfigError::invalid("route.name", "must not be empty"));
56 }
57 if normalize_public_host(&self.public_host).is_empty() {
58 return Err(ConfigError::invalid(
59 "route.public_host",
60 "must not be empty",
61 ));
62 }
63 if self.public_host.contains(',') {
64 return Err(ConfigError::invalid(
65 "route.public_host",
66 "must contain exactly one host",
67 ));
68 }
69 if !self.public_path.starts_with('/') {
70 return Err(ConfigError::invalid(
71 "route.public_path",
72 "must start with /",
73 ));
74 }
75 if !self.backend_url.trim().is_empty() {
76 crate::security::ssrf::validate_url(
77 &self.backend_url,
78 crate::security::ssrf::OutboundPolicy::AdminProtectedBackend,
79 )
80 .map_err(|_| ConfigError::invalid("route.backend_url", "is not allowed"))?;
81 }
82 if self.scopes.iter().any(|scope| scope.trim().is_empty()) {
83 return Err(ConfigError::invalid(
84 "route.scopes",
85 "must not contain empty scopes",
86 ));
87 }
88 if let Some(target) = &self.target {
89 if target
90 .upstreams
91 .iter()
92 .any(|upstream| upstream.trim().is_empty())
93 {
94 return Err(ConfigError::invalid(
95 "route.target.upstreams",
96 "must not contain empty upstream names",
97 ));
98 }
99 if target
100 .services
101 .iter()
102 .any(|service| service.trim().is_empty())
103 {
104 return Err(ConfigError::invalid(
105 "route.target.services",
106 "must not contain empty service names",
107 ));
108 }
109 }
110 Ok(())
111 }
112
113 #[must_use]
114 pub fn public_resource(&self) -> String {
115 format!(
116 "https://{}{}",
117 normalize_public_host(&self.public_host),
118 self.public_path
119 )
120 }
121}
122
123#[must_use]
124pub fn normalize_public_host(host: &str) -> String {
125 host.trim().trim_end_matches('.').to_ascii_lowercase()
126}
127
128#[cfg(test)]
129#[path = "protected_routes_tests.rs"]
130mod tests;