This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 09:29:10 +02:00
1 parent 2e4aa4df4d
commit 87e571c170
106 files changed
+17952 -6185

No files matched your search

+247 -24
View File
@@ -60,6 +60,8 @@ pub struct Config {
#[serde(default, skip_serializing_if = "crate::tls::Settings::is_empty")]
pub tls: crate::tls::Settings,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub web_services: Vec<crate::web_server::Service>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
@@ -169,6 +171,12 @@ pub struct CompiledBackend {
/// directly. Used by the guarded control-plane reverse-proxy path.
pub targets_admin_listener: bool,
}
#[derive(Debug, Clone)]
pub enum CompiledSource {
ReverseProxy,
WebServer(Arc<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
@@ -182,7 +190,8 @@ pub struct CompiledRoute {
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub backend: Arc<CompiledBackend>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders,
}
@@ -280,8 +289,7 @@ impl Snapshot {
&& frontend.websocket_rfc8441
&& frontend.http2_enabled
&& frontend.bindings.iter().any(|binding| {
binding.matches(local, tls)
&& if tls { binding.http2 } else { binding.h2c }
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
})
})
}
@@ -854,11 +862,47 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
let mut web_services = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
let compiled = Arc::new(
service
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
for host in &mut config.vhosts {
host.validate()
.with_context(|| format!("vhost {}", host.id))?;
let validate_source = |source: &crate::vhost::Source| -> Result<()> {
if let crate::vhost::Source::WebServer { web_service_id } = source {
ensure!(
web_services.contains_key(web_service_id),
"vhost {} references unknown Web Service {}",
host.id,
web_service_id
);
}
Ok(())
};
validate_source(&host.source)?;
for route in &host.routes {
if route.response.is_none() {
validate_source(&route.source)?;
}
}
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
}
ensure!(
@@ -1183,16 +1227,20 @@ pub fn compile_with_active(
backend.id = format!("{}/{}", host.id, definition.id);
compiled_members.push(Arc::new(backend));
}
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
&host.backends,
compiled_members,
)?;
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
pool.reuse_observations(old);
}
let pool = Arc::new(pool);
let pool = if compiled_members.is_empty() {
None
} else {
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
&host.backends,
compiled_members,
)?;
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
pool.reuse_observations(old);
}
Some(Arc::new(pool))
};
let metrics = previous
.and_then(|s| s.vhost_metrics.get(&host.id))
.cloned()
@@ -1270,11 +1318,31 @@ pub fn compile_with_active(
proxy_timeouts: host.proxy_timeouts.clone(),
});
if host.enabled {
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} Reverse Proxy source has no backend pool",
host.id
)
})?;
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(pool.members[0].backend.clone()),
)
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
None,
None,
),
};
let fallback = CompiledRoute {
backend_id: None,
bindings: frontend.bindings.clone(),
frontend: Some(frontend.clone()),
pool: Some(pool.clone()),
pool: fallback_pool,
cache: None,
id: format!("{}/main", host.id),
path_prefix: "/".into(),
@@ -1282,7 +1350,8 @@ pub fn compile_with_active(
path_regex: None,
rewrite: crate::vhost::PathRewrite::default(),
response: None,
backend: pool.members[0].backend.clone(),
source: fallback_source,
backend: fallback_backend,
acl: None,
headers: crate::headers::CompiledHeaders::default(),
};
@@ -1341,19 +1410,56 @@ pub fn compile_with_active(
validate_location_response(route.response.as_ref())?;
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
let headers = route.headers.compile()?;
if let Some(id) = &route.backend_id {
ensure!(
host.backends.iter().any(|b| &b.id == id),
"vhost path references unknown backend {}",
id
);
if route.source.is_reverse_proxy() {
if let Some(id) = &route.backend_id {
ensure!(
host.backends.iter().any(|b| &b.id == id),
"vhost path references unknown backend {}",
id
);
}
}
if host.enabled && route.enabled {
let (source, route_pool, backend) = if route.response.is_some() {
// Local return is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} location {} Reverse Proxy source has no backend pool",
host.id,
route.id
)
})?;
let backend = if let Some(id) = route.backend_id.as_deref() {
pool.members
.iter()
.find(|member| member.definition.id == id)
.map(|member| member.backend.clone())
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
} else {
pool.members[0].backend.clone()
};
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(backend),
)
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
None,
None,
),
}
};
let compiled = CompiledRoute {
backend_id: route.backend_id.clone(),
bindings: frontend.bindings.clone(),
frontend: Some(frontend.clone()),
pool: Some(pool.clone()),
pool: route_pool,
cache: route.cache,
id: format!("{}/{}", host.id, route.id),
path_prefix: route.path_prefix.clone(),
@@ -1361,7 +1467,8 @@ pub fn compile_with_active(
path_regex,
rewrite: route.rewrite.clone(),
response: route.response.clone(),
backend: pool.members[0].backend.clone(),
source,
backend,
acl,
headers,
};
@@ -1380,7 +1487,9 @@ pub fn compile_with_active(
.or_default()
.push(frontend.clone());
}
pools.push(pool);
if let Some(pool) = pool {
pools.push(pool);
}
}
}
let quic_tls = tls
@@ -1537,6 +1646,120 @@ mod tests {
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
}
#[test]
fn web_server_main_source_needs_no_backend_pool() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
config.vhosts[0].backends.clear();
config.vhosts[0].routes.clear();
let snapshot = compile(config, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
assert!(route.pool.is_none());
assert!(route.backend.is_none());
assert!(snapshot.pools.is_empty());
}
#[test]
fn web_server_and_reverse_proxy_can_share_one_vhost() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let host = &mut config.vhosts[0];
host.source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
host.routes = vec![crate::vhost::PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
source: crate::vhost::Source::ReverseProxy,
..Default::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let root = snapshot.select("localhost", "/").unwrap();
let api = snapshot.select("localhost", "/api/test").unwrap();
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
assert!(api.pool.is_some());
assert!(api.backend.is_some());
}
#[test]
fn one_web_service_can_be_reused_by_multiple_vhosts() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "shared-site".into(),
name: "Shared site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let first = &mut config.vhosts[0];
first.source = crate::vhost::Source::WebServer {
web_service_id: "shared-site".into(),
};
first.backends.clear();
first.routes.clear();
let mut second = first.clone();
second.id = "shared-second".into();
second.frontend.hosts = vec!["second.localhost".into()];
config.vhosts.push(second);
let snapshot = compile(config, 1, &policy()).unwrap();
let first_route = snapshot.select("localhost", "/").unwrap();
let second_route = snapshot.select("second.localhost", "/").unwrap();
assert!(
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
assert!(
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
}
#[test]
fn unknown_web_service_reference_is_rejected() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "missing".into(),
};
let error = compile(config, 1, &policy()).unwrap_err().to_string();
assert!(error.contains("unknown Web Service"), "{error}");
}
#[test]
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
let source: crate::vhost::Source =
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
assert_eq!(source, crate::vhost::Source::ReverseProxy);
let route: crate::vhost::PathRoute =
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
}
#[test]
fn host_normalization() {
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");