use std::borrow::Cow;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::matcher::Pattern;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GatewayConfig {
pub upstreams: BTreeMap<String, Upstream>,
pub routes: Vec<GatewayRoute>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reserved_paths: Vec<String>,
}
impl GatewayConfig {
pub fn is_enabled(&self) -> bool {
!self.routes.is_empty()
}
pub fn is_empty(&self) -> bool {
self.upstreams.is_empty() && self.routes.is_empty()
}
pub fn match_route(&self, path: &str) -> Option<&GatewayRoute> {
self.routes.iter().find(|route| {
Pattern::compile(&route.matches)
.map(|pattern| pattern.is_match(path))
.unwrap_or(false)
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GatewaySiteCaps {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_project_upstreams: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_backends: Option<usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub project_egress_hosts: Vec<String>,
}
impl GatewaySiteCaps {
pub fn is_empty(&self) -> bool {
self.max_project_upstreams.is_none()
&& self.max_backends.is_none()
&& self.project_egress_hosts.is_empty()
}
}
pub fn gateway_host_matches(glob: &str, host: &str) -> bool {
let glob = glob.trim().to_ascii_lowercase();
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
if glob == "**" {
return !host.is_empty();
}
if let Some(suffix) = glob.strip_prefix("**.") {
return host == suffix || host.ends_with(&format!(".{suffix}"));
}
if let Some(suffix) = glob.strip_prefix("*.") {
return match host.strip_suffix(&format!(".{suffix}")) {
Some(label) => !label.is_empty() && !label.contains('.'),
None => false,
};
}
glob == host
}
pub fn gateway_host_allowed(allowlist: &[String], host: &str) -> bool {
allowlist.is_empty() || allowlist.iter().any(|g| gateway_host_matches(g, host))
}
#[cfg(all(feature = "gateway-gate-mutation", not(test), not(debug_assertions)))]
compile_error!(
"gateway-gate-mutation is a test-only anti-hollow seam and must never be compiled into a release \
build — remove it from the feature set"
);
#[cfg(any(test, feature = "gateway-gate-mutation"))]
pub fn gateway_mutation() -> Option<String> {
std::env::var("BOATRAMP_GATEWAY_MUTATION").ok()
}
#[cfg(not(any(test, feature = "gateway-gate-mutation")))]
pub fn gateway_mutation() -> Option<String> {
None
}
pub fn project_raw_egress_allowed(
up: &Upstream,
site_allow: &[String],
node_allow: &[String],
) -> bool {
if gateway_mutation().as_deref() == Some("skip_egress") {
return true;
}
upstream_literal_hosts(up)
.iter()
.all(|h| gateway_host_allowed(site_allow, h) && gateway_host_allowed(node_allow, h))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GatewayTier {
Operator,
Project,
}
pub struct GatewayMatch<'a> {
pub tier: GatewayTier,
pub upstream_name: &'a str,
pub upstream: &'a Upstream,
pub via_reference: bool,
}
pub enum GatewayResolution<'a> {
NoMatch,
UnknownUpstream {
tier: GatewayTier,
name: String,
},
Matched(GatewayMatch<'a>),
}
pub fn resolve_gateway<'a>(
project: Option<&'a GatewayConfig>,
site: Option<&'a GatewayConfig>,
path: &str,
) -> GatewayResolution<'a> {
if let Some(site) = site
&& let Some(route) = site.match_route(path)
{
return match site.upstreams.get(&route.upstream) {
Some(up) => GatewayResolution::Matched(GatewayMatch {
tier: GatewayTier::Operator,
upstream_name: &route.upstream,
upstream: up,
via_reference: false,
}),
None => GatewayResolution::UnknownUpstream {
tier: GatewayTier::Operator,
name: route.upstream.clone(),
},
};
}
let Some(project) = project else {
return GatewayResolution::NoMatch;
};
if gateway_mutation().as_deref() != Some("skip_reserved")
&& let Some(site) = site
&& site.reserved_paths.iter().any(|r| {
Pattern::compile(r)
.map(|p| p.is_match(path))
.unwrap_or(true)
})
{
return GatewayResolution::NoMatch;
}
if let Some(route) = project.match_route(path) {
if let Some(up) = project.upstreams.get(&route.upstream) {
return GatewayResolution::Matched(GatewayMatch {
tier: GatewayTier::Project,
upstream_name: &route.upstream,
upstream: up,
via_reference: false,
});
}
if let Some(site) = site
&& let Some(up) = site.upstreams.get(&route.upstream)
&& up.shared
{
return GatewayResolution::Matched(GatewayMatch {
tier: GatewayTier::Operator,
upstream_name: &route.upstream,
upstream: up,
via_reference: true,
});
}
return GatewayResolution::UnknownUpstream {
tier: GatewayTier::Project,
name: route.upstream.clone(),
};
}
GatewayResolution::NoMatch
}
pub fn validate_site_gateway(site: &GatewayConfig) -> Result<(), crate::ConfigError> {
for route in &site.routes {
Pattern::compile(&route.matches)?;
if !site.upstreams.contains_key(&route.upstream) {
return Err(crate::ConfigError::parse(format!(
"site gateway route `{}` references unknown upstream `{}`",
route.matches, route.upstream
)));
}
}
for reserved in &site.reserved_paths {
Pattern::compile(reserved)?;
}
Ok(())
}
pub fn validate_project_gateway(
project: &GatewayConfig,
site: Option<&GatewayConfig>,
caps: Option<&GatewaySiteCaps>,
) -> Result<(), crate::ConfigError> {
if let Some(caps) = caps {
if let Some(max) = caps.max_project_upstreams
&& project.upstreams.len() > max
{
return Err(crate::ConfigError::parse(format!(
"project gateway declares {} upstreams, over the site ceiling of {max} \
(GatewaySiteCaps.max_project_upstreams)",
project.upstreams.len()
)));
}
if let Some(max) = caps.max_backends {
for (name, up) in &project.upstreams {
let backends = upstream_backend_count(up);
if backends > max {
return Err(crate::ConfigError::parse(format!(
"project gateway upstream `{name}` declares {backends} backends, over the \
site ceiling of {max} (GatewaySiteCaps.max_backends)"
)));
}
}
}
}
for (name, up) in &project.upstreams {
if up.shared {
return Err(crate::ConfigError::parse(format!(
"project gateway upstream `{name}` sets `shared` — only a site/operator upstream may \
be a referenceable grant (a project cannot grant reach it does not hold)"
)));
}
if site.is_some_and(|s| s.upstreams.get(name).is_some_and(|u| u.shared)) {
return Err(crate::ConfigError::parse(format!(
"project gateway upstream `{name}` collides with a shared site upstream of the same \
name — rename the project upstream, or drop it to reference the site grant"
)));
}
if up.compute.is_none() {
let egress = caps
.map(|c| c.project_egress_hosts.as_slice())
.unwrap_or(&[]);
for host in upstream_literal_hosts(up) {
if host_is_literal_private(&host) {
return Err(crate::ConfigError::parse(format!(
"project gateway upstream `{name}` targets a private/internal address \
`{host}` — the project tier may only reach its own compute, a public host, \
or a shared site upstream; declare the private backend as a site upstream \
and mark it `shared`"
)));
}
if !gateway_host_allowed(egress, &host) {
return Err(crate::ConfigError::parse(format!(
"project gateway upstream `{name}` targets host `{host}`, which the site's \
GatewaySiteCaps.project_egress_hosts allowlist does not permit"
)));
}
}
}
}
for route in &project.routes {
Pattern::compile(&route.matches)?;
let in_project = project.upstreams.contains_key(&route.upstream);
if let Some(s) = site {
let in_shared_site = s.upstreams.get(&route.upstream).is_some_and(|u| u.shared);
if !in_project && !in_shared_site {
return Err(crate::ConfigError::parse(format!(
"project gateway route `{}` references upstream `{}`, which is neither a project \
upstream nor a shared site upstream",
route.matches, route.upstream
)));
}
}
}
Ok(())
}
fn upstream_backend_count(up: &Upstream) -> usize {
if !up.targets.is_empty() {
up.targets.len()
} else if !up.target.trim().is_empty() {
1
} else {
0
}
}
fn upstream_literal_hosts(up: &Upstream) -> Vec<String> {
let mut hosts = Vec::new();
if !up.target.trim().is_empty()
&& let Some(h) = extract_host(&up.target)
{
hosts.push(h);
}
for t in &up.targets {
if let Some(h) = extract_host(t) {
hosts.push(h);
}
}
if let Some(d) = &up.discover
&& let Some(h) = extract_host(&d.host)
{
hosts.push(h);
}
hosts
}
fn extract_host(s: &str) -> Option<String> {
let s = s.trim();
if s.is_empty() {
return None;
}
let parsed = if s.contains("://") {
url::Url::parse(s).ok()?
} else {
url::Url::parse(&format!("http://{s}")).ok()?
};
match parsed.host()? {
url::Host::Domain(d) => Some(d.to_string()),
url::Host::Ipv4(a) => Some(a.to_string()),
url::Host::Ipv6(a) => Some(a.to_string()),
}
}
fn host_is_literal_private(host: &str) -> bool {
match host.parse::<std::net::IpAddr>() {
Ok(ip) => !ip_is_public(ip),
Err(_) => false,
}
}
fn ip_is_public(ip: std::net::IpAddr) -> bool {
use std::net::IpAddr;
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
!(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| o[0] == 0
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40))
}
IpAddr::V6(v6) => {
let seg = v6.segments();
!(v6.is_loopback()
|| v6.is_unspecified()
|| (seg[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xffc0) == 0xfe80)
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Upstream {
pub target: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<String>,
#[serde(skip_serializing_if = "LbPolicy::is_default")]
pub lb: LbPolicy,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub regions: BTreeMap<String, crate::geo::Region>,
#[serde(skip_serializing_if = "crate::geo::RegionMap::is_empty")]
pub region_map: crate::geo::RegionMap,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_region_header: Option<String>,
#[serde(skip_serializing_if = "is_zero")]
pub max_retries: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub passive_health: Option<PassiveHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_health: Option<ActiveHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discover: Option<Discovery>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compute: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host_header: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strip_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "is_false")]
pub tls_insecure: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_buffer_bytes: Option<u64>,
#[serde(skip_serializing_if = "HeaderOps::is_empty")]
pub header_up: HeaderOps,
#[serde(skip_serializing_if = "HeaderOps::is_empty")]
pub header_down: HeaderOps,
#[serde(default, skip_serializing_if = "is_false")]
pub shared: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LbPolicy {
#[default]
RoundRobin,
Random,
Nearest,
}
impl LbPolicy {
fn is_default(&self) -> bool {
matches!(self, Self::RoundRobin)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PassiveHealth {
pub max_fails: u32,
pub fail_timeout_ms: u64,
}
impl Default for PassiveHealth {
fn default() -> Self {
Self {
max_fails: 3,
fail_timeout_ms: 10_000,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ActiveHealth {
pub path: String,
pub interval_ms: u64,
pub timeout_ms: u64,
pub healthy_threshold: u32,
pub unhealthy_threshold: u32,
pub expected_status: u16,
}
impl Default for ActiveHealth {
fn default() -> Self {
Self {
path: "/".to_string(),
interval_ms: 10_000,
timeout_ms: 2_000,
healthy_threshold: 2,
unhealthy_threshold: 3,
expected_status: 200,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Discovery {
pub host: String,
pub port: u16,
pub scheme: String,
pub refresh_secs: u64,
}
impl Default for Discovery {
fn default() -> Self {
Self {
host: String::new(),
port: 0,
scheme: "http".to_string(),
refresh_secs: 30,
}
}
}
impl Upstream {
pub fn static_backends(&self) -> Vec<&str> {
if !self.targets.is_empty() {
self.targets.iter().map(String::as_str).collect()
} else if !self.target.is_empty() {
vec![self.target.as_str()]
} else {
Vec::new()
}
}
pub fn forward_path<'a>(&self, request_path: &'a str) -> Cow<'a, str> {
let Some(prefix) = self.strip_prefix.as_deref() else {
return Cow::Borrowed(request_path);
};
let prefix = prefix.trim_end_matches('/');
if prefix.is_empty() {
return Cow::Borrowed(request_path);
}
match request_path.strip_prefix(prefix) {
Some("") => Cow::Borrowed("/"),
Some(rest) if rest.starts_with('/') => Cow::Borrowed(rest),
_ => Cow::Borrowed(request_path),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HeaderOps {
pub set: BTreeMap<String, String>,
pub remove: Vec<String>,
}
impl HeaderOps {
pub fn is_empty(&self) -> bool {
self.set.is_empty() && self.remove.is_empty()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GatewayRoute {
#[serde(rename = "match")]
pub matches: String,
pub upstream: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn upstream(target: &str, strip: Option<&str>) -> Upstream {
Upstream {
target: target.to_string(),
strip_prefix: strip.map(str::to_string),
..Default::default()
}
}
#[test]
fn match_route_first_wins() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([
("api".to_string(), upstream("http://10.0.0.5:8080", None)),
("app".to_string(), upstream("http://10.0.0.6:3000", None)),
]),
routes: vec![
GatewayRoute {
matches: "/api/**".into(),
upstream: "api".into(),
},
GatewayRoute {
matches: "/**".into(),
upstream: "app".into(),
},
],
..Default::default()
};
assert_eq!(cfg.match_route("/api/v1/x").unwrap().upstream, "api");
assert_eq!(cfg.match_route("/anything").unwrap().upstream, "app");
assert!(cfg.is_enabled());
assert!(!GatewayConfig::default().is_enabled());
}
#[test]
fn strip_prefix_only_on_segment_boundary() {
let u = upstream("http://h:1", Some("/app"));
assert_eq!(u.forward_path("/app"), "/");
assert_eq!(u.forward_path("/app/foo/bar"), "/foo/bar");
assert_eq!(u.forward_path("/application"), "/application"); let u2 = upstream("http://h:1", Some("/app/"));
assert_eq!(u2.forward_path("/app/x"), "/x");
let u3 = upstream("http://h:1", None);
assert_eq!(u3.forward_path("/app/x"), "/app/x");
}
#[test]
fn config_round_trips_through_json() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([(
"api".to_string(),
Upstream {
target: "https://10.0.0.5:8443/base".into(),
host_header: Some("internal.local".into()),
strip_prefix: Some("/api".into()),
connect_timeout_ms: Some(2000),
request_timeout_ms: Some(30000),
tls_insecure: true,
header_up: HeaderOps {
set: BTreeMap::from([("x-svc".to_string(), "boatramp".to_string())]),
remove: vec!["cookie".into()],
},
header_down: HeaderOps::default(),
..Default::default()
},
)]),
routes: vec![GatewayRoute {
matches: "/api/**".into(),
upstream: "api".into(),
}],
..Default::default()
};
let json = serde_json::to_string(&cfg).unwrap();
assert_eq!(serde_json::from_str::<GatewayConfig>(&json).unwrap(), cfg);
assert!(json.contains("\"match\":\"/api/**\""));
let single = &serde_json::from_str::<GatewayConfig>(&json)
.unwrap()
.upstreams["api"];
assert_eq!(single.static_backends(), vec!["https://10.0.0.5:8443/base"]);
}
#[test]
fn nearest_upstream_round_trips_with_region_config() {
let up = Upstream {
targets: vec!["http://a".into(), "http://b".into()],
lb: LbPolicy::Nearest,
regions: BTreeMap::from([
("http://a".to_string(), "us-east".to_string()),
("http://b".to_string(), "eu-west".to_string()),
]),
region_map: crate::geo::RegionMap::from_edges([(
"us-east".to_string(),
"eu-west".to_string(),
4,
)]),
client_region_header: Some("fly-region".into()),
max_retries: 2,
..Default::default()
};
let json = serde_json::to_string(&up).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), up);
assert!(json.contains("\"lb\":\"nearest\""));
let plain = serde_json::to_string(&Upstream {
target: "http://x".into(),
..Default::default()
})
.unwrap();
assert!(!plain.contains("region_map"));
assert!(!plain.contains("client_region_header"));
assert!(!plain.contains("\"regions\""));
}
#[test]
fn pool_round_trips_and_resolves_backends() {
let cfg = GatewayConfig {
upstreams: BTreeMap::from([(
"pool".to_string(),
Upstream {
targets: vec!["http://10.0.0.5:8080".into(), "http://10.0.0.6:8080".into()],
lb: LbPolicy::Random,
max_retries: 2,
passive_health: Some(PassiveHealth {
max_fails: 5,
fail_timeout_ms: 30_000,
}),
..Default::default()
},
)]),
routes: vec![GatewayRoute {
matches: "/**".into(),
upstream: "pool".into(),
}],
..Default::default()
};
let json = serde_json::to_string(&cfg).unwrap();
let back = serde_json::from_str::<GatewayConfig>(&json).unwrap();
assert_eq!(back, cfg);
assert_eq!(
back.upstreams["pool"].static_backends(),
vec!["http://10.0.0.5:8080", "http://10.0.0.6:8080"]
);
assert!(json.contains("\"lb\":\"random\""));
}
#[test]
fn dns_discovery_round_trips() {
let u = Upstream {
discover: Some(Discovery {
host: "svc.internal".into(),
port: 8080,
scheme: "http".into(),
refresh_secs: 15,
}),
..Default::default()
};
let json = serde_json::to_string(&u).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), u);
assert!(u.static_backends().is_empty());
}
#[test]
fn active_health_round_trips() {
let u = Upstream {
targets: vec!["http://10.0.0.1:80".into(), "http://10.0.0.2:80".into()],
active_health: Some(ActiveHealth {
path: "/healthz".into(),
interval_ms: 5_000,
timeout_ms: 1_000,
healthy_threshold: 2,
unhealthy_threshold: 3,
expected_status: 200,
}),
..Default::default()
};
let json = serde_json::to_string(&u).unwrap();
assert_eq!(serde_json::from_str::<Upstream>(&json).unwrap(), u);
assert!(json.contains("\"path\":\"/healthz\""));
}
#[test]
fn defaults_omit_new_fields_on_the_wire() {
let json = serde_json::to_string(&upstream("http://h:1", None)).unwrap();
for absent in [
"targets",
"lb",
"max_retries",
"passive_health",
"active_health",
"discover",
] {
assert!(
!json.contains(absent),
"default upstream leaked {absent}: {json}"
);
}
}
#[test]
fn resolve_gateway_operator_precedence_and_reserved_paths() {
let mut site = GatewayConfig::default();
site.upstreams
.insert("ops".into(), upstream("http://10.0.0.9:80", None));
site.routes.push(GatewayRoute {
matches: "/admin/**".into(),
upstream: "ops".into(),
});
site.reserved_paths.push("/.well-known/**".into());
let mut project = GatewayConfig::default();
project.upstreams.insert(
"web".into(),
Upstream {
compute: Some("web".into()),
..Default::default()
},
);
project.routes.push(GatewayRoute {
matches: "/.well-known/**".into(),
upstream: "web".into(),
});
project.routes.push(GatewayRoute {
matches: "/**".into(),
upstream: "web".into(),
});
match resolve_gateway(Some(&project), Some(&site), "/admin/x") {
GatewayResolution::Matched(m) => {
assert_eq!(m.tier, GatewayTier::Operator);
assert_eq!(m.upstream_name, "ops");
}
_ => panic!("operator route must win"),
}
assert!(matches!(
resolve_gateway(Some(&project), Some(&site), "/.well-known/acme"),
GatewayResolution::NoMatch
));
match resolve_gateway(Some(&project), Some(&site), "/hello") {
GatewayResolution::Matched(m) => {
assert_eq!(m.tier, GatewayTier::Project);
assert!(!m.via_reference);
}
_ => panic!("project route must serve a normal path"),
}
}
#[test]
fn resolve_gateway_reference_is_operator_tier_and_opaque() {
let mut site = GatewayConfig::default();
site.upstreams.insert(
"db".into(),
Upstream {
target: "http://10.0.0.5:5432".into(),
shared: true,
..Default::default()
},
);
let mut project = GatewayConfig::default();
project.routes.push(GatewayRoute {
matches: "/db/**".into(),
upstream: "db".into(),
});
match resolve_gateway(Some(&project), Some(&site), "/db/x") {
GatewayResolution::Matched(m) => {
assert_eq!(m.tier, GatewayTier::Operator);
assert!(m.via_reference);
assert_eq!(m.upstream.target, "http://10.0.0.5:5432");
}
_ => panic!("a shared reference must resolve"),
}
let mut site_unshared = GatewayConfig::default();
site_unshared
.upstreams
.insert("db".into(), upstream("http://10.0.0.5:5432", None));
assert!(matches!(
resolve_gateway(Some(&project), Some(&site_unshared), "/db/x"),
GatewayResolution::UnknownUpstream {
tier: GatewayTier::Project,
..
}
));
}
#[test]
fn validate_project_gateway_enforces_the_target_space() {
let mut p = GatewayConfig::default();
p.upstreams.insert(
"w".into(),
Upstream {
compute: Some("w".into()),
..Default::default()
},
);
p.routes.push(GatewayRoute {
matches: "/**".into(),
upstream: "w".into(),
});
assert!(validate_project_gateway(&p, None, None).is_ok());
let mut public = GatewayConfig::default();
public
.upstreams
.insert("ext".into(), upstream("https://api.example.com", None));
assert!(validate_project_gateway(&public, None, None).is_ok());
for bad in [
"http://10.0.0.5:8080",
"http://127.0.0.1:9000",
"http://169.254.169.254/latest",
"http://[fd00::1]:80",
] {
let mut g = GatewayConfig::default();
g.upstreams.insert("bad".into(), upstream(bad, None));
assert!(
validate_project_gateway(&g, None, None).is_err(),
"a project upstream to {bad} must be refused"
);
}
let mut sh = GatewayConfig::default();
sh.upstreams.insert(
"x".into(),
Upstream {
target: "https://ok.example.com".into(),
shared: true,
..Default::default()
},
);
assert!(validate_project_gateway(&sh, None, None).is_err());
let mut site = GatewayConfig::default();
site.upstreams.insert(
"grant".into(),
Upstream {
target: "http://10.0.0.9:80".into(),
shared: true,
..Default::default()
},
);
let mut ref_ok = GatewayConfig::default();
ref_ok.routes.push(GatewayRoute {
matches: "/g/**".into(),
upstream: "grant".into(),
});
assert!(validate_project_gateway(&ref_ok, Some(&site), None).is_ok());
let mut ref_bad = GatewayConfig::default();
ref_bad.routes.push(GatewayRoute {
matches: "/g/**".into(),
upstream: "absent".into(),
});
assert!(validate_project_gateway(&ref_bad, Some(&site), None).is_err());
}
#[test]
fn validate_project_gateway_enforces_site_caps() {
let mut p = GatewayConfig::default();
p.upstreams
.insert("a".into(), upstream("https://a.example.com", None));
p.upstreams
.insert("b".into(), upstream("https://b.example.com", None));
let count1 = GatewaySiteCaps {
max_project_upstreams: Some(1),
..Default::default()
};
assert!(validate_project_gateway(&p, None, Some(&count1)).is_err());
let count2 = GatewaySiteCaps {
max_project_upstreams: Some(2),
..Default::default()
};
assert!(validate_project_gateway(&p, None, Some(&count2)).is_ok());
let mut pool = GatewayConfig::default();
pool.upstreams.insert(
"pool".into(),
Upstream {
targets: vec![
"https://1.example.com".into(),
"https://2.example.com".into(),
"https://3.example.com".into(),
],
..Default::default()
},
);
let be2 = GatewaySiteCaps {
max_backends: Some(2),
..Default::default()
};
assert!(validate_project_gateway(&pool, None, Some(&be2)).is_err());
let be3 = GatewaySiteCaps {
max_backends: Some(3),
..Default::default()
};
assert!(validate_project_gateway(&pool, None, Some(&be3)).is_ok());
let allow = GatewaySiteCaps {
project_egress_hosts: vec!["*.good.example".into()],
..Default::default()
};
let mut evil = GatewayConfig::default();
evil.upstreams
.insert("x".into(), upstream("https://evil.example.net", None));
assert!(
validate_project_gateway(&evil, None, Some(&allow)).is_err(),
"a host outside the site egress allowlist is refused"
);
let mut good = GatewayConfig::default();
good.upstreams
.insert("x".into(), upstream("https://api.good.example", None));
assert!(
validate_project_gateway(&good, None, Some(&allow)).is_ok(),
"a host matching the site allowlist glob is allowed"
);
let mut comp = GatewayConfig::default();
comp.upstreams.insert(
"w".into(),
Upstream {
compute: Some("w".into()),
..Default::default()
},
);
assert!(
validate_project_gateway(&comp, None, Some(&allow)).is_ok(),
"own-compute is exempt from the egress-host allowlist"
);
assert!(validate_project_gateway(&p, None, None).is_ok());
}
#[test]
fn gateway_host_glob_matching() {
assert!(gateway_host_matches("api.example.com", "API.Example.COM")); assert!(gateway_host_matches("*.example.com", "api.example.com")); assert!(!gateway_host_matches("*.example.com", "a.b.example.com")); assert!(!gateway_host_matches("*.example.com", "example.com")); assert!(gateway_host_matches("**.example.com", "a.b.example.com")); assert!(gateway_host_matches("**.example.com", "example.com")); assert!(gateway_host_matches("**", "anything.at.all"));
assert!(!gateway_host_matches("good.com", "evil.com"));
assert!(gateway_host_allowed(&[], "anything"));
assert!(gateway_host_allowed(
&["*.ok.com".to_string()],
"api.ok.com"
));
assert!(!gateway_host_allowed(
&["*.ok.com".to_string()],
"api.evil.com"
));
}
#[test]
fn project_raw_egress_intersects_site_and_node() {
let up = upstream("https://api.vendor.com", None);
assert!(project_raw_egress_allowed(&up, &[], &[]));
assert!(project_raw_egress_allowed(
&up,
&["*.vendor.com".to_string()],
&[]
));
assert!(project_raw_egress_allowed(
&up,
&[],
&["api.vendor.com".to_string()]
));
assert!(!project_raw_egress_allowed(
&up,
&["*.vendor.com".to_string()],
&["*.other.com".to_string()]
));
assert!(!project_raw_egress_allowed(
&up,
&["*.other.com".to_string()],
&["*.vendor.com".to_string()]
));
let pool = Upstream {
targets: vec!["https://a.vendor.com".into(), "https://evil.com".into()],
..Default::default()
};
assert!(!project_raw_egress_allowed(
&pool,
&["*.vendor.com".to_string()],
&[]
));
}
#[test]
fn egress_host_matches_the_connection_parser_on_a_backslash_authority() {
assert_eq!(
extract_host("http://evil.com\\@allowed.com").as_deref(),
Some("evil.com"),
);
let evil = upstream("http://evil.com\\@allowed.com", None);
assert!(!project_raw_egress_allowed(
&evil,
&["allowed.com".to_string()],
&[]
));
let ok = upstream("https://allowed.com/path", None);
assert!(project_raw_egress_allowed(
&ok,
&["allowed.com".to_string()],
&[]
));
}
#[test]
fn gateway_mutation_gate() {
let mut site = GatewayConfig::default();
site.reserved_paths.push("/.well-known/**".into());
let mut project = GatewayConfig::default();
project
.upstreams
.insert("u".into(), upstream("https://api.good.example", None));
project.routes.push(GatewayRoute {
matches: "/.well-known/**".into(),
upstream: "u".into(),
});
assert!(
matches!(
resolve_gateway(Some(&project), Some(&site), "/.well-known/acme-challenge/x"),
GatewayResolution::NoMatch
),
"a project route must NOT capture a site reserved path (skip_reserved mutation flips this)"
);
let evil = upstream("https://evil.example.net", None);
assert!(
!project_raw_egress_allowed(&evil, &["*.good.example".to_string()], &[]),
"an out-of-allowlist project raw host must be refused (skip_egress mutation flips this)"
);
println!("GATEWAY MUTATION GATE OK: reserved-path precedence + egress allowlist both hold");
}
}