Skip to main content

soma_gateway/config/
protected_routes.rs

1use 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;