479 lines
16 KiB
Rust
479 lines
16 KiB
Rust
impl Snapshot {
|
|
fn find_host_pattern<T>(host: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> {
|
|
if let Some(value) = lookup(host) {
|
|
return Some(value);
|
|
}
|
|
let last_dot = host.rfind('.')?;
|
|
let mut wildcard = [0u8; 256];
|
|
wildcard[..2].copy_from_slice(b"*.");
|
|
for (index, _) in host.match_indices('.') {
|
|
if index == last_dot {
|
|
break;
|
|
}
|
|
let suffix = &host[index + 1..];
|
|
let len = suffix.len() + 2;
|
|
if len <= wildcard.len() {
|
|
wildcard[2..len].copy_from_slice(suffix.as_bytes());
|
|
let key = std::str::from_utf8(&wildcard[..len])
|
|
.expect("a wildcard plus a UTF-8 host is valid UTF-8");
|
|
if let Some(value) = lookup(key) {
|
|
return Some(value);
|
|
}
|
|
} else if let Some(value) = lookup(&format!("*.{suffix}")) {
|
|
return Some(value);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool {
|
|
if self.host_acls.is_empty() {
|
|
return true;
|
|
}
|
|
Self::find_host_pattern(host, |pattern| {
|
|
self.host_acls.get(pattern).map(|acl| acl.allows(peer))
|
|
})
|
|
.unwrap_or(true)
|
|
}
|
|
pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool {
|
|
self.frontends.values().flatten().any(|frontend| {
|
|
frontend.websockets
|
|
&& frontend.websocket_rfc8441
|
|
&& frontend.http2_enabled
|
|
&& frontend.bindings.iter().any(|binding| {
|
|
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
|
|
})
|
|
})
|
|
}
|
|
pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool {
|
|
self.frontends.values().flatten().any(|frontend| {
|
|
frontend.websockets
|
|
&& frontend.websocket_rfc9220
|
|
&& frontend.http3_enabled
|
|
&& frontend.bindings.iter().any(|binding| {
|
|
binding.tls && binding.http3.is_some() && binding.matches(local, true)
|
|
})
|
|
})
|
|
}
|
|
pub fn select_on(
|
|
&self,
|
|
host: &str,
|
|
path: &str,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
) -> Option<&CompiledRoute> {
|
|
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
|
|
}
|
|
pub fn select_on_arc(
|
|
&self,
|
|
host: &str,
|
|
path: &str,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
) -> Option<&Arc<CompiledRoute>> {
|
|
Self::find_host_pattern(host, |pattern| {
|
|
let fallback = self.fallbacks.get(pattern).and_then(|routes| {
|
|
routes
|
|
.iter()
|
|
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
|
|
})?;
|
|
self.routes
|
|
.get(pattern)
|
|
.and_then(|routes| {
|
|
select_route(routes, path, |r| {
|
|
r.bindings.iter().any(|l| l.matches(local, tls))
|
|
})
|
|
})
|
|
.or(Some(fallback))
|
|
})
|
|
}
|
|
pub fn frontend(
|
|
&self,
|
|
host: &str,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
) -> Option<&Arc<CompiledFrontend>> {
|
|
Self::find_host_pattern(host, |pattern| {
|
|
self.frontends.get(pattern).and_then(|frontends| {
|
|
frontends
|
|
.iter()
|
|
.find(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
|
|
})
|
|
})
|
|
}
|
|
pub fn resolve_logging_vhost(
|
|
&self,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
routed_vhost: Option<&str>,
|
|
authority: Option<&str>,
|
|
host: Option<&str>,
|
|
sni: Option<&str>,
|
|
) -> Option<ResolvedLogVhost> {
|
|
if let Some(routed) = routed_vhost.filter(|value| !value.is_empty()) {
|
|
if self.frontends.values().flatten().any(|frontend| {
|
|
frontend.id == routed
|
|
&& frontend
|
|
.bindings
|
|
.iter()
|
|
.any(|binding| binding.matches(local, tls))
|
|
}) {
|
|
return Some(ResolvedLogVhost {
|
|
vhost: routed.to_owned(),
|
|
matched_by: "router",
|
|
});
|
|
}
|
|
}
|
|
for (candidate, matched_by) in [(authority, "authority"), (host, "host"), (sni, "sni")] {
|
|
let Some(candidate) = candidate else {
|
|
continue;
|
|
};
|
|
let Ok(normalized) = request_host(candidate) else {
|
|
continue;
|
|
};
|
|
if let Some(frontend) = self.frontend(&normalized, local, tls) {
|
|
return Some(ResolvedLogVhost {
|
|
vhost: frontend.id.clone(),
|
|
matched_by,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
pub fn redirect_on(
|
|
&self,
|
|
host: &str,
|
|
path: &str,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
) -> Option<(
|
|
&Arc<crate::redirect::CompiledHost>,
|
|
&crate::redirect::CompiledRule,
|
|
)> {
|
|
if self.redirects.is_empty() {
|
|
return None;
|
|
}
|
|
Self::find_host_pattern(host, |pattern| {
|
|
self.redirects.get(pattern).and_then(|redirects| {
|
|
redirects.iter().find_map(|redirect| {
|
|
if !redirect
|
|
.bindings
|
|
.iter()
|
|
.any(|binding| binding.matches(local, tls))
|
|
{
|
|
return None;
|
|
}
|
|
redirect
|
|
.rules
|
|
.iter()
|
|
.find(|rule| rule.matches(path, tls))
|
|
.map(|rule| (redirect, rule))
|
|
})
|
|
})
|
|
})
|
|
}
|
|
pub fn not_found_host_on(
|
|
&self,
|
|
host: &str,
|
|
local: SocketAddr,
|
|
tls: bool,
|
|
) -> Option<&Arc<crate::not_found::CompiledHost>> {
|
|
if self.not_found_hosts.is_empty() {
|
|
return None;
|
|
}
|
|
Self::find_host_pattern(host, |pattern| {
|
|
self.not_found_hosts.get(pattern).and_then(|items| {
|
|
items.iter().find(|item| {
|
|
item.bindings
|
|
.iter()
|
|
.any(|binding| binding.matches(local, tls))
|
|
})
|
|
})
|
|
})
|
|
}
|
|
pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> {
|
|
Self::find_host_pattern(host, |pattern| {
|
|
let fallback = self
|
|
.fallbacks
|
|
.get(pattern)
|
|
.and_then(|routes| routes.first())?;
|
|
self.routes
|
|
.get(pattern)
|
|
.and_then(|routes| select_route(routes, path, |_| true))
|
|
.or(Some(fallback))
|
|
.map(Arc::as_ref)
|
|
})
|
|
}
|
|
}
|
|
fn select_route<'a, F>(
|
|
routes: &'a [Arc<CompiledRoute>],
|
|
path: &str,
|
|
allowed: F,
|
|
) -> Option<&'a Arc<CompiledRoute>>
|
|
where
|
|
F: Fn(&CompiledRoute) -> bool,
|
|
{
|
|
if let Some(exact) = routes.iter().find(|route| {
|
|
!route.internal
|
|
&& allowed(route)
|
|
&& route.match_type == crate::vhost::PathMatch::Exact
|
|
&& route.path_prefix == path
|
|
}) {
|
|
return Some(exact);
|
|
}
|
|
let prefix = routes
|
|
.iter()
|
|
.filter(|route| {
|
|
!route.internal
|
|
&& allowed(route)
|
|
&& matches!(
|
|
route.match_type,
|
|
crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority
|
|
)
|
|
&& prefix_matches(&route.path_prefix, path)
|
|
})
|
|
.max_by_key(|route| route.path_prefix.len());
|
|
if prefix.is_some_and(|route| route.match_type == crate::vhost::PathMatch::PrefixPriority) {
|
|
return prefix;
|
|
}
|
|
if let Some(regex) = routes.iter().find(|route| {
|
|
!route.internal
|
|
&& allowed(route)
|
|
&& matches!(
|
|
route.match_type,
|
|
crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive
|
|
)
|
|
&& route
|
|
.path_regex
|
|
.as_ref()
|
|
.is_some_and(|regex| regex.is_match(path))
|
|
}) {
|
|
return Some(regex);
|
|
}
|
|
prefix
|
|
}
|
|
impl CompiledRoute {
|
|
pub fn redirect_location(
|
|
&self,
|
|
path: &str,
|
|
uri: &http::Uri,
|
|
) -> Result<Option<http::HeaderValue>> {
|
|
let Some(spec) = &self.redirect else {
|
|
return Ok(None);
|
|
};
|
|
let target = if let Some(regex) = &spec.rewrite_regex {
|
|
ensure!(
|
|
regex.is_match(path),
|
|
"location redirect rewrite regex did not match"
|
|
);
|
|
regex.replace(path, spec.target.as_str()).into_owned()
|
|
} else {
|
|
spec.target.clone()
|
|
};
|
|
let mut target =
|
|
url::Url::parse(&target).context("invalid compiled location redirect target")?;
|
|
if spec.preserve_query {
|
|
if let Some(query) = uri.query() {
|
|
let merged = match target.query() {
|
|
Some(existing) if !existing.is_empty() => format!("{existing}&{query}"),
|
|
_ => query.to_owned(),
|
|
};
|
|
target.set_query(Some(&merged));
|
|
}
|
|
}
|
|
Ok(Some(
|
|
http::HeaderValue::from_str(target.as_str())
|
|
.context("invalid redirect Location header")?,
|
|
))
|
|
}
|
|
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
|
|
use crate::vhost::RewriteMode;
|
|
let rewritten = match self.rewrite.mode {
|
|
RewriteMode::Preserve => return Ok(None),
|
|
RewriteMode::StripPrefix => {
|
|
let tail = path.strip_prefix(&self.path_prefix).unwrap_or("");
|
|
if tail.is_empty() {
|
|
"/".to_owned()
|
|
} else {
|
|
tail.to_owned()
|
|
}
|
|
}
|
|
RewriteMode::ReplacePrefix => {
|
|
let tail = path.strip_prefix(&self.path_prefix).unwrap_or("");
|
|
let base = self.rewrite.value.trim_end_matches('/');
|
|
if tail.is_empty() {
|
|
if base.is_empty() {
|
|
"/".to_owned()
|
|
} else {
|
|
base.to_owned()
|
|
}
|
|
} else if base.is_empty() || base == "/" {
|
|
tail.to_owned()
|
|
} else {
|
|
format!("{base}{tail}")
|
|
}
|
|
}
|
|
RewriteMode::Regex => self
|
|
.path_regex
|
|
.as_ref()
|
|
.expect("regex rewrite was validated")
|
|
.replace(path, self.rewrite.value.as_str())
|
|
.into_owned(),
|
|
};
|
|
crate::acl::validate_decoded_routing_path(&rewritten)
|
|
.context("invalid rewritten location path")?;
|
|
Ok(Some(rewritten))
|
|
}
|
|
}
|
|
pub fn prefix_matches(prefix: &str, path: &str) -> bool {
|
|
prefix == "/"
|
|
|| path == prefix
|
|
|| path
|
|
.strip_prefix(prefix)
|
|
.is_some_and(|tail| tail.starts_with('/'))
|
|
}
|
|
pub(crate) fn valid_id(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& id
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
|
}
|
|
pub fn normalize_host(value: &str) -> Result<String> {
|
|
let host = value
|
|
.strip_suffix('.')
|
|
.unwrap_or(value)
|
|
.to_ascii_lowercase();
|
|
ensure!(
|
|
!host.is_empty() && host.len() <= 253,
|
|
"hostname length must be 1..253"
|
|
);
|
|
ensure!(
|
|
host.split('.').all(|label| {
|
|
!label.is_empty()
|
|
&& label.len() <= 63
|
|
&& !label.starts_with('-')
|
|
&& !label.ends_with('-')
|
|
&& label
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
|
}),
|
|
"invalid hostname: use an ASCII hostname without a port or wildcard"
|
|
);
|
|
Ok(host)
|
|
}
|
|
pub fn normalize_host_pattern(value: &str) -> Result<String> {
|
|
let value = value.trim();
|
|
if let Some(base) = value.strip_prefix("*.") {
|
|
ensure!(
|
|
!base.contains('*'),
|
|
"wildcard hostname allows only one leading *."
|
|
);
|
|
let base = normalize_host(base)?;
|
|
ensure!(
|
|
base.contains('.') && base.parse::<IpAddr>().is_err(),
|
|
"wildcard hostname requires a DNS suffix such as *.example.com"
|
|
);
|
|
return Ok(format!("*.{base}"));
|
|
}
|
|
ensure!(
|
|
!value.contains('*'),
|
|
"wildcard hostname allows only one leading *."
|
|
);
|
|
normalize_host(value)
|
|
}
|
|
pub fn request_host(value: &str) -> Result<String> {
|
|
ensure!(!value.contains('@'), "invalid Host");
|
|
let authority: Authority = value.parse().context("invalid Host authority")?;
|
|
let raw_host = authority.host();
|
|
let suffix = &authority.as_str()[raw_host.len()..];
|
|
if !suffix.is_empty() {
|
|
let port = suffix.strip_prefix(':').context("invalid Host port")?;
|
|
if !port.is_empty() {
|
|
let number: u16 = port.parse().context("invalid Host port")?;
|
|
ensure!(number > 0, "invalid Host port");
|
|
}
|
|
}
|
|
let host = raw_host.trim_start_matches('[').trim_end_matches(']');
|
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
|
return Ok(ip.to_string());
|
|
}
|
|
normalize_host(host)
|
|
}
|
|
fn ipv4_policy(ip: Ipv4Addr, allow_private: bool) -> Result<()> {
|
|
let [a, b, _, _] = ip.octets();
|
|
ensure!(
|
|
a != 0 && a < 224 && !ip.is_broadcast(),
|
|
"unspecified/reserved/multicast target blocked"
|
|
);
|
|
ensure!(
|
|
!(a == 169 && b == 254),
|
|
"link-local and metadata targets are blocked"
|
|
);
|
|
ensure!(
|
|
!(a == 100 && (64..=127).contains(&b)),
|
|
"shared-address/metadata range is blocked"
|
|
);
|
|
ensure!(
|
|
!(a == 198 && (b == 18 || b == 19)),
|
|
"benchmark range is blocked"
|
|
);
|
|
ensure!(
|
|
!ip.is_documentation(),
|
|
"documentation address is not a backend"
|
|
);
|
|
ensure!(
|
|
allow_private || (!ip.is_private() && !ip.is_loopback()),
|
|
"private/loopback backend requires allow_private=true"
|
|
);
|
|
Ok(())
|
|
}
|
|
pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> {
|
|
match ip {
|
|
IpAddr::V4(ip) => ipv4_policy(ip, allow_private),
|
|
IpAddr::V6(ip) => {
|
|
if let Some(v4) = ip.to_ipv4_mapped() {
|
|
return ipv4_policy(v4, allow_private);
|
|
}
|
|
let s = ip.segments();
|
|
ensure!(
|
|
!ip.is_unspecified() && !ip.is_multicast(),
|
|
"unspecified/multicast target blocked"
|
|
);
|
|
ensure!(
|
|
s[0] & 0xffc0 != 0xfe80,
|
|
"IPv6 link-local targets are blocked"
|
|
);
|
|
ensure!(
|
|
!(s[0] == 0x2001 && s[1] == 0x0db8),
|
|
"documentation address is not a backend"
|
|
);
|
|
let ula = s[0] & 0xfe00 == 0xfc00;
|
|
let global =
|
|
s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0);
|
|
ensure!(
|
|
global || ula || ip.is_loopback(),
|
|
"special IPv6 target blocked"
|
|
);
|
|
ensure!(
|
|
allow_private || (!ula && !ip.is_loopback()),
|
|
"private/loopback backend requires allow_private=true"
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
pub(crate) fn compile_backend(
|
|
backend: &Backend,
|
|
policy: &ValidationPolicy,
|
|
bindings: &[SocketAddr],
|
|
keepalive: &crate::balance::Keepalive,
|
|
) -> Result<CompiledBackend> {
|
|
compile_backend_with_timeout(
|
|
backend,
|
|
policy,
|
|
bindings,
|
|
keepalive,
|
|
std::time::Duration::from_secs(10),
|
|
)
|
|
}
|